diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml new file mode 100644 index 0000000..91acd96 --- /dev/null +++ b/.github/workflows/release-plz.yml @@ -0,0 +1,58 @@ +name: Release-plz + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + release-plz-release: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: read + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: release-plz/action@v0.5 + with: + command: release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + + release-plz-pr: + needs: release-plz-release + if: always() + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + concurrency: + group: release-plz-${{ github.ref }} + cancel-in-progress: false + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: release-plz/action@v0.5 + id: release-plz + with: + command: release-pr + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Job summary + if: always() + run: | + echo "## Release PR" >> "$GITHUB_STEP_SUMMARY" + if [ "${{ steps.release-plz.outputs.prs_created }}" = "true" ]; then + echo "Release PR created." >> "$GITHUB_STEP_SUMMARY" + else + echo "No release PR created." >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/Cargo.toml b/Cargo.toml index 72e4e88..9831404 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,11 +19,14 @@ path = "src/main.rs" anyhow = "1" clap = { version = "4", features = ["derive", "env"] } dirs = "5" +rand = "0.8" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "gzip", "stream"] } -rpassword = "7" serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +unicode-width = "0.2" +url = "2" +webbrowser = "1" [profile.release] opt-level = "z" diff --git a/README.md b/README.md index c3eee20..3eb72c2 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,33 @@ -# wavekat-cli (`wk`) +

+ + WaveKat CLI + +

-Command-line client for the [WaveKat platform](https://github.com/wavekat/wavekat-platform). +[![Crates.io](https://img.shields.io/crates/v/wavekat-cli.svg)](https://crates.io/crates/wavekat-cli) -> [!WARNING] -> Early development. The auth model is intentionally minimal in v1 (paste a -> session cookie). A proper device-code flow lands together with the export -> feature on the platform side. +Command-line client (`wk`) for the [WaveKat platform](https://platform.wavekat.com). + +> [!NOTE] +> Early development. `wk login` now uses a browser-based loopback OAuth +> handshake (no more pasting cookies). Export commands are still pending. ## What it does today | Command | What it calls | What it shows | |---------|---------------|---------------| -| `wk login` | `GET /api/me` to verify | stores `{base_url, session_cookie}` under your config dir | -| `wk logout` | — | removes the stored credentials | +| `wk login` | loopback OAuth + `GET /api/me` | stores `{base_url, token}` under your config dir | +| `wk logout` | `POST /api/auth/cli/tokens/revoke-current` | revokes the token server-side and removes the local file | | `wk me` | `GET /api/me` | your login, id, name, email, role | | `wk projects list` | `GET /api/projects` | paginated table of projects you can see | -| `wk projects show ` | `GET /api/projects/{id}` | project detail (raw JSON) | -| `wk annotations list ` | `GET /api/projects/{id}/annotations` | paginated annotations (raw JSON) | +| `wk projects show ` | `GET /api/projects/{id}` | project summary (`--json` for raw) | +| `wk annotations list ` | `GET /api/projects/{id}/annotations` | paginated table with inline ASR (`--json` for raw) | -Pagination on every list (`--page`, `--page-size`). Filters on annotations: -`--label`, `--review-status`, `--file-id`, `--created-by`. Run any command -with `--help` for the full set. +Pagination on every list (`--page`, `--page-size`, default page size 20). +The footer shows the current page and prints a ready-to-paste `Next:` +line when more pages exist. Filters on annotations: `--label`, +`--review-status`, `--file-id`, `--created-by`. Run any command with +`--help` for the full set. ## Install @@ -40,33 +47,48 @@ Not yet — these will land once the first tagged release is cut. Releases will ship prebuilt binaries for macOS (Apple Silicon + Intel) and Linux (x86_64 + aarch64). -## Sign in (v1: paste-the-cookie) - -The platform today only authenticates browser sessions via a signed -`wk_session` cookie set after GitHub OAuth. Until the platform exposes a -CLI-friendly auth flow (planned alongside the dataset export feature), `wk` -reuses your browser session. +## Sign in ```sh -wk login --base-url https://platform.wavekat.com +wk login +# (or: wk login --base-url https://platform.wavekat.com) ``` -You'll be told to: +What happens: + +1. `wk` binds an ephemeral port on `127.0.0.1`. +2. Your default browser opens to `/cli-login`. If you're not + already signed in, you're bounced through the normal "Sign in with + GitHub" flow first and come back automatically. +3. You click **Authorize** on the platform's confirmation page. +4. The platform redirects the browser to the loopback URL with a freshly + minted token; `wk` captures it, verifies against `/api/me`, and writes + it to your config file. +5. The browser tab shows "You can close this tab" and you're done in your + terminal. -1. Open the platform URL in your browser and sign in with GitHub. -2. Open dev tools → Application → Cookies → the platform origin. -3. Copy the value of the `wk_session` cookie and paste it at the prompt - (input is hidden). +The token is a long-lived `wkcli_…` bearer credential. You can list and +revoke tokens from your platform profile page; `wk logout` revokes the +current token before clearing the local file. -`wk login` calls `/api/me` to verify the cookie before storing it. The cookie -has a 7-day TTL; you'll need to re-run `wk login` after that. +### Headless / SSH -You can also pass it non-interactively: +If no browser is available on the local machine, run: ```sh -WK_BASE_URL=https://platform.wavekat.com WK_SESSION='…' wk login -# or -wk login --base-url … --session … +wk login --no-browser +``` + +`wk` prints the authorization URL — open it on any browser that can +reach the loopback port (typically with `ssh -L 1234:127.0.0.1:1234 +remote-host`, then open the URL the CLI prints). + +### CI / pre-minted token + +Pre-mint a token from the SPA (or the API), then: + +```sh +WK_TOKEN='wkcli_…' WK_BASE_URL='https://platform.wavekat.com' wk login ``` ### Where credentials are stored @@ -91,7 +113,11 @@ wk projects list --page-size 5 wk projects list --json | jq '.projects[].name' -wk annotations list --label end_of_turn --review-status approved \ +# Default: human-readable table with the ASR snippet under each row. +wk annotations list --label end_of_turn --review-status approved + +# Pipe raw JSON into jq for scripting. +wk annotations list --label end_of_turn --review-status approved --json \ | jq '.annotations | length' ``` @@ -100,14 +126,10 @@ wk annotations list --label end_of_turn --review-status approved \ The next milestone for the CLI is the **dataset export** feature, landing together with the matching platform changes. It will add: -- Proper device-code login (no more cookie pasting). - `wk exports create` / `list` / `show` / `download`. - A built-in adapter that materialises the canonical snapshot into the HuggingFace `datasets` format Pipecat `smart-turn` consumes. -See the platform's [docs/06-export.md](https://github.com/wavekat/wavekat-platform/blob/main/docs/06-export.md) -for the design. - ## License Apache-2.0. See [LICENSE](LICENSE). diff --git a/release-plz.toml b/release-plz.toml new file mode 100644 index 0000000..68387a3 --- /dev/null +++ b/release-plz.toml @@ -0,0 +1,3 @@ +[workspace] +git_tag_enable = true +git_release_enable = true diff --git a/src/client.rs b/src/client.rs index 46109ba..4403dc7 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,5 +1,5 @@ use anyhow::{anyhow, Context, Result}; -use reqwest::header::{HeaderMap, HeaderValue, COOKIE}; +use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, COOKIE}; use serde::de::DeserializeOwned; use serde::Serialize; @@ -18,11 +18,26 @@ impl Client { pub fn new(cfg: &AuthConfig) -> Result { let mut headers = HeaderMap::new(); - let cookie = format!("wk_session={}", cfg.session_cookie); - headers.insert( - COOKIE, - HeaderValue::from_str(&cookie).context("session cookie contained invalid bytes")?, - ); + // Prefer the bearer token (new flow). Fall back to the legacy + // session cookie so existing auth.json files keep working until + // the user re-runs `wk login`. + if let Some(token) = cfg.token.as_deref() { + let value = format!("Bearer {token}"); + headers.insert( + AUTHORIZATION, + HeaderValue::from_str(&value).context("token contained invalid bytes")?, + ); + } else if let Some(cookie) = cfg.session_cookie.as_deref() { + let value = format!("wk_session={cookie}"); + headers.insert( + COOKIE, + HeaderValue::from_str(&value).context("session cookie contained invalid bytes")?, + ); + } else { + return Err(anyhow!( + "no credentials in config — run `wk login` to authenticate" + )); + } let inner = reqwest::Client::builder() .default_headers(headers) .user_agent(concat!("wavekat-cli/", env!("CARGO_PKG_VERSION"))) @@ -48,6 +63,27 @@ impl Client { decode(url, resp).await } + pub async fn post_empty(&self, path: &str) -> Result<()> { + let url = self.url(path); + let resp = self + .inner + .post(&url) + .send() + .await + .with_context(|| format!("POST {url}"))?; + let status = resp.status(); + if !status.is_success() { + let text = resp.text().await.unwrap_or_default(); + let snippet = if text.len() > 500 { + &text[..500] + } else { + &text + }; + return Err(anyhow!("{} {}: {}", status.as_u16(), url, snippet)); + } + Ok(()) + } + pub async fn get_json_query( &self, path: &str, diff --git a/src/commands/annotations.rs b/src/commands/annotations.rs index dfdb5cf..a8daef5 100644 --- a/src/commands/annotations.rs +++ b/src/commands/annotations.rs @@ -1,8 +1,9 @@ use anyhow::Result; use clap::{Args as ClapArgs, Subcommand}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use crate::client::Client; +use crate::style; #[derive(Subcommand)] pub enum Cmd { @@ -30,6 +31,9 @@ pub struct ListArgs { /// Filter to a labeller's user id #[arg(long)] created_by: Option, + /// Print raw JSON instead of a table + #[arg(long)] + json: bool, } #[derive(Serialize, Default)] @@ -47,6 +51,29 @@ struct ListQuery { created_by: Option, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct Annotation { + id: String, + file_name: Option, + label_key: String, + label_value: i64, + start_sec: f64, + end_sec: f64, + review_status: Option, + asr_text: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ListResponse { + annotations: Vec, + page: u32, + page_size: u32, + total: u32, + total_pages: u32, +} + pub async fn run(cmd: Cmd) -> Result<()> { let client = Client::from_config()?; match cmd { @@ -64,7 +91,154 @@ async fn list(client: &Client, args: ListArgs) -> Result<()> { file_id: args.file_id, created_by: args.created_by, }; - let v: serde_json::Value = client.get_json_query(&path, &query).await?; - println!("{}", serde_json::to_string_pretty(&v)?); + if args.json { + let v: serde_json::Value = client.get_json_query(&path, &query).await?; + println!("{}", serde_json::to_string_pretty(&v)?); + return Ok(()); + } + let resp: ListResponse = client.get_json_query(&path, &query).await?; + if resp.annotations.is_empty() { + println!("No annotations."); + return Ok(()); + } + println!( + "{} {} {} {} {}", + style::bold(&format!("{:<10}", "ID")), + style::bold(&format!("{:<24}", "FILE")), + style::bold(&format!("{:<18}", "LABEL")), + style::bold(&format!("{:<16}", "RANGE")), + style::bold("REVIEW"), + ); + for a in &resp.annotations { + let id_short = a.id.get(..8).unwrap_or(&a.id); + let file = truncate(a.file_name.as_deref().unwrap_or("-"), 24); + let label_text = truncate(&format!("{}={}", a.label_key, a.label_value), 18); + let range = format!("{:.1}–{:.1}s", a.start_sec, a.end_sec); + // Pad in raw bytes first, then style — ANSI codes don't count toward + // the visible width, so styling has to wrap an already-padded cell. + println!( + "{} {file:<24} {} {} {}", + style::dim(&format!("{id_short:<10}")), + style::cyan(&format!("{label_text:<18}")), + style::dim(&format!("{range:<16}")), + style::review(a.review_status.as_deref()), + ); + if let Some(text) = a.asr_text.as_deref() { + let trimmed = text.trim(); + if !trimmed.is_empty() { + println!(" {}", style::dim(&truncate_width(trimmed, 80))); + } + } + } + println!( + "\n{}", + style::dim(&format!( + "Page {}/{} · {} annotation(s) total · pageSize {}", + resp.page, resp.total_pages, resp.total, resp.page_size + )), + ); + if resp.page < resp.total_pages { + println!( + "{} wk annotations list {} --page {}{}", + style::dim("Next:"), + args.project_id, + resp.page + 1, + if resp.page_size != 20 { + format!(" --page-size {}", resp.page_size) + } else { + String::new() + }, + ); + } Ok(()) } + +fn truncate(s: &str, n: usize) -> String { + if s.chars().count() > n { + let mut out: String = s.chars().take(n.saturating_sub(1)).collect(); + out.push('…'); + out + } else { + s.to_string() + } +} + +/// Truncate by *visible* terminal width (CJK chars count as 2). Stops as +/// soon as appending the next char would push us over `cols - 1`, leaving +/// room for the trailing ellipsis. Falls back to no-op when the input +/// already fits. +fn truncate_width(s: &str, cols: usize) -> String { + use unicode_width::UnicodeWidthChar; + let mut total = 0usize; + for c in s.chars() { + total += c.width().unwrap_or(0); + if total > cols { + let budget = cols.saturating_sub(1); + let mut used = 0usize; + let mut out = String::new(); + for c in s.chars() { + let w = c.width().unwrap_or(0); + if used + w > budget { + break; + } + out.push(c); + used += w; + } + out.push('…'); + return out; + } + } + s.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use unicode_width::UnicodeWidthStr; + + #[test] + fn truncate_passes_short_strings() { + assert_eq!(truncate("hi", 5), "hi"); + assert_eq!(truncate("", 5), ""); + // Boundary: exactly n chars must not be truncated. + assert_eq!(truncate("abcde", 5), "abcde"); + } + + #[test] + fn truncate_clips_long_strings_with_ellipsis() { + let out = truncate("abcdefghij", 5); + assert_eq!(out.chars().count(), 5); + assert!(out.ends_with('…')); + assert_eq!(out, "abcd…"); + } + + #[test] + fn truncate_counts_chars_not_bytes() { + // Multi-byte chars should be counted once, not by byte length. + let out = truncate("日本語テキスト", 4); + assert_eq!(out.chars().count(), 4); + assert!(out.ends_with('…')); + } + + #[test] + fn truncate_width_passes_when_fits() { + assert_eq!(truncate_width("hello", 80), "hello"); + } + + #[test] + fn truncate_width_respects_visible_columns() { + let out = truncate_width("abcdefghij", 5); + assert!(out.ends_with('…')); + assert!(out.width() <= 5); + assert_eq!(out, "abcd…"); + } + + #[test] + fn truncate_width_treats_cjk_as_double_width() { + // Each CJK char is width 2; budget of 5 fits at most two chars + // plus the ellipsis (2 + 2 + 1 = 5). + let out = truncate_width("日本語テキスト", 5); + assert!(out.ends_with('…')); + assert!(out.width() <= 5); + } +} diff --git a/src/commands/login.rs b/src/commands/login.rs index b7f0fcf..0012d38 100644 --- a/src/commands/login.rs +++ b/src/commands/login.rs @@ -1,26 +1,59 @@ -use anyhow::{anyhow, Context, Result}; +// `wk login` — loopback OAuth flow. +// +// The CLI: +// 1. Binds a TCP listener on 127.0.0.1:. +// 2. Generates a one-shot CSRF state. +// 3. Opens the platform's `/cli-login` page in the user's default +// browser, with the loopback URL and state as query params. +// 4. Blocks on `accept()` until the platform redirects the browser +// back to the loopback URL with `?token=…&state=…` (success) or +// `?error=…&state=…` (cancel/error). +// 5. Verifies the token by calling `/api/me`, persists it, and +// returns control. +// +// The loopback HTTP server is intentionally hand-rolled (std::net): +// one request, one response, no concurrency, no need to drag in a +// framework. The handler reads at most a small fixed amount per +// connection so a stray local probe can't tie us up. +// +// `--no-browser` falls back to printing the URL for the user to open +// manually — useful on a remote host where no browser is available. +// `--token` skips the dance entirely (e.g. for CI), accepting a +// pre-minted `wkcli_…` token. + +use anyhow::{anyhow, bail, Context, Result}; use clap::Args as ClapArgs; -use std::io::{self, Write}; +use rand::RngCore; +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener, TcpStream}; +use std::time::Duration; use crate::client::Client; use crate::config::{self, AuthConfig}; +use crate::style; + +const DEFAULT_BASE_URL: &str = "https://platform.wavekat.com"; -/// `wk login` arguments. -/// -/// In v1 we don't have a CLI-friendly auth endpoint on the platform yet, so -/// the simplest path is "paste your session cookie". A proper device-code -/// flow lands together with the export feature on the platform side. #[derive(ClapArgs)] pub struct Args { /// Base URL of the WaveKat platform (e.g. https://platform.wavekat.com). - /// If omitted, the previously stored value is reused, or you'll be prompted. + /// If omitted, the previously stored value is reused, then the public + /// platform URL. #[arg(long, env = "WK_BASE_URL")] base_url: Option, - /// Value of the `wk_session` cookie. Read from `WK_SESSION` if set, - /// otherwise prompted for interactively (input is hidden). - #[arg(long, env = "WK_SESSION")] - session: Option, + /// Skip opening the browser; print the URL instead. Useful on a remote + /// host. The CLI still listens on a loopback port — open the URL on + /// any browser that can reach this machine on that port (typically via + /// SSH port-forward). + #[arg(long)] + no_browser: bool, + + /// Pre-minted `wkcli_…` bearer token. Skips the browser handshake + /// entirely and just verifies + saves the token. Intended for CI. + /// Read from `WK_TOKEN` if set. + #[arg(long, env = "WK_TOKEN")] + token: Option, } pub async fn run(args: Args) -> Result<()> { @@ -29,54 +62,342 @@ pub async fn run(args: Args) -> Result<()> { let base_url = args .base_url .or_else(|| existing.as_ref().map(|c| c.base_url.clone())) - .map(|s| s.trim_end_matches('/').to_string()) - .map(Ok) - .unwrap_or_else(|| prompt_line("Base URL"))?; + .unwrap_or_else(|| DEFAULT_BASE_URL.to_string()) + .trim_end_matches('/') + .to_string(); - println!( - "\nTo sign in:\n 1. Open {base_url} in your browser and sign in via GitHub.\n 2. Open dev tools → Application → Cookies → {base_url}.\n 3. Copy the value of the `wk_session` cookie.\n" - ); - - let session = match args.session { - Some(s) => s, - None => rpassword::prompt_password("wk_session cookie value: ") - .context("reading session cookie")?, + let token = match args.token { + Some(t) => t.trim().to_string(), + None => browser_handshake(&base_url, args.no_browser)?, }; - let session = session.trim().to_string(); - if session.is_empty() { - return Err(anyhow!("session cookie cannot be empty")); + if token.is_empty() { + bail!("got an empty token from the platform"); } let cfg = AuthConfig { base_url, - session_cookie: session, + token: Some(token), + session_cookie: None, }; - // Verify the cookie before persisting it. /api/me is the cheapest - // identity check the platform exposes and matches what the web app uses. + // Verify against /api/me before persisting — keeps a typo or a + // half-broken handshake from poisoning the saved config. let client = Client::new(&cfg)?; let me: serde_json::Value = client .get_json("/api/me") .await - .context("verifying credentials against /api/me")?; + .context("verifying token against /api/me")?; let login = me.get("login").and_then(|v| v.as_str()).unwrap_or("?"); let role = me.get("role").and_then(|v| v.as_str()).unwrap_or("?"); config::save(&cfg)?; let path = config::auth_path()?; - println!("Signed in as {login} (role: {role})."); - println!("Credentials saved to {}", path.display()); + println!( + "{} Signed in as {} ({} {}).", + style::green("✓"), + style::bold(login), + style::dim("role:"), + style::role(role), + ); + println!( + "{} {}", + style::dim("Credentials saved to"), + style::dim(&path.display().to_string()), + ); Ok(()) } -fn prompt_line(label: &str) -> Result { - print!("{label}: "); - io::stdout().flush()?; - let mut buf = String::new(); - io::stdin().read_line(&mut buf)?; - let buf = buf.trim().to_string(); - if buf.is_empty() { - return Err(anyhow!("{label} cannot be empty")); +fn browser_handshake(base_url: &str, no_browser: bool) -> Result { + // Bind to an ephemeral port on loopback only — never on 0.0.0.0, + // since anything bound to a non-loopback interface could be reached + // by another host on the network for the brief window we listen. + let listener = TcpListener::bind(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0)) + .context("binding loopback listener for the OAuth handshake")?; + let port = listener.local_addr()?.port(); + + let state = random_state(); + let name = client_name(); + let callback = format!("http://127.0.0.1:{port}/callback"); + + let auth_url = format!( + "{base_url}/cli-login?callback={cb}&state={state}&name={name}", + cb = url::form_urlencoded::byte_serialize(callback.as_bytes()).collect::(), + state = url::form_urlencoded::byte_serialize(state.as_bytes()).collect::(), + name = url::form_urlencoded::byte_serialize(name.as_bytes()).collect::(), + ); + + if no_browser { + println!("Open this URL in any browser to finish signing in:\n {auth_url}\n"); + } else { + println!("Opening {base_url} in your browser to sign in…"); + if let Err(e) = webbrowser::open(&auth_url) { + eprintln!("(couldn't open the browser automatically: {e})"); + println!("Open this URL manually:\n {auth_url}\n"); + } + } + println!("Waiting for the browser to redirect back (Ctrl-C to cancel)…"); + + // 5 minutes is generous — if a user takes longer than that to log in + // they probably got distracted. Re-running `wk login` is cheap. + listener + .set_nonblocking(false) + .context("listener: set_blocking")?; + let deadline = std::time::Instant::now() + Duration::from_secs(5 * 60); + + loop { + if std::time::Instant::now() > deadline { + bail!("timed out waiting for the browser to complete the login"); + } + let (stream, _) = listener + .accept() + .context("accepting browser callback connection")?; + match handle_callback(stream, &state) { + Ok(Some(token)) => return Ok(token), + Ok(None) => continue, // probe / preflight; keep listening + Err(e) => { + // Surface but don't abort — a stray request shouldn't break + // the real one. (e.g. devtools sending a HEAD probe.) + eprintln!("(ignored bad callback request: {e})"); + continue; + } + } + } +} + +fn handle_callback(mut stream: TcpStream, expected_state: &str) -> Result> { + stream.set_read_timeout(Some(Duration::from_secs(5))).ok(); + stream.set_write_timeout(Some(Duration::from_secs(5))).ok(); + + // Read just the request line + headers. Cap at 8 KiB — the URL we care + // about is well under that and we never need the body. + let mut reader = BufReader::new(stream.try_clone()?); + let mut request_line = String::new(); + reader + .read_line(&mut request_line) + .context("reading HTTP request line")?; + // Drain headers (and discard) so the browser sees the connection close + // cleanly. Bounded to keep a malicious local probe from streaming forever. + let mut header_bytes = 0usize; + let mut line = String::new(); + loop { + line.clear(); + let n = reader.read_line(&mut line)?; + if n == 0 || line == "\r\n" || line == "\n" { + break; + } + header_bytes += n; + if header_bytes > 8192 { + bail!("request headers too large"); + } + } + + // Parse "GET /callback?... HTTP/1.1" + let mut parts = request_line.split_whitespace(); + let method = parts.next().unwrap_or(""); + let target = parts.next().unwrap_or(""); + if method != "GET" { + respond(&mut stream, 405, "method not allowed", "method not allowed")?; + return Ok(None); + } + if !target.starts_with("/callback") { + // Browsers fetch /favicon.ico; OS sometimes probes /. Reply 404 and + // keep listening — only /callback matters. + respond(&mut stream, 404, "not found", "not found")?; + return Ok(None); + } + + let query = target.split_once('?').map(|(_, q)| q).unwrap_or(""); + let mut token: Option = None; + let mut state: Option = None; + let mut error: Option = None; + for (k, v) in url::form_urlencoded::parse(query.as_bytes()) { + match k.as_ref() { + "token" => token = Some(v.into_owned()), + "state" => state = Some(v.into_owned()), + "error" => error = Some(v.into_owned()), + _ => {} + } + } + + // Constant-time enough: states are short, equal length on success path. + if state.as_deref() != Some(expected_state) { + respond( + &mut stream, + 400, + "bad state", + "

State mismatch

Re-run wk login to start over.

", + )?; + bail!("state mismatch — refusing token"); + } + + if let Some(err) = error { + respond( + &mut stream, + 200, + "OK", + &format!( + "

Login cancelled

You can close this tab and re-run wk login.

reason: {}

", + html_escape(&err), + ), + )?; + bail!("login cancelled in browser ({err})"); + } + + let Some(tok) = token else { + respond(&mut stream, 400, "missing token", "missing token")?; + bail!("callback missing token"); + }; + + respond( + &mut stream, + 200, + "OK", + "WaveKat CLI signed in

You're signed in.

You can close this tab and return to your terminal.

", + )?; + Ok(Some(tok)) +} + +fn respond(stream: &mut TcpStream, status: u16, reason: &str, body: &str) -> Result<()> { + let body_bytes = body.as_bytes(); + let resp = format!( + "HTTP/1.1 {status} {reason}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {len}\r\nConnection: close\r\n\r\n", + len = body_bytes.len(), + ); + stream.write_all(resp.as_bytes())?; + stream.write_all(body_bytes)?; + // Drain any unread bytes so the kernel doesn't RST the connection + // before the browser reads our response. + let _ = stream.flush(); + let mut sink = [0u8; 64]; + let _ = stream.set_read_timeout(Some(Duration::from_millis(50))); + let _ = stream.read(&mut sink); + Ok(()) +} + +fn random_state() -> String { + let mut bytes = [0u8; 24]; + rand::thread_rng().fill_bytes(&mut bytes); + // URL-safe base64 (manual, to avoid pulling in another crate). + base64url(&bytes) +} + +fn base64url(bytes: &[u8]) -> String { + const ALPHA: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let mut out = String::with_capacity((bytes.len() * 4).div_ceil(3)); + let mut i = 0; + while i + 3 <= bytes.len() { + let n = ((bytes[i] as u32) << 16) | ((bytes[i + 1] as u32) << 8) | (bytes[i + 2] as u32); + out.push(ALPHA[((n >> 18) & 0x3f) as usize] as char); + out.push(ALPHA[((n >> 12) & 0x3f) as usize] as char); + out.push(ALPHA[((n >> 6) & 0x3f) as usize] as char); + out.push(ALPHA[(n & 0x3f) as usize] as char); + i += 3; + } + let rem = bytes.len() - i; + if rem == 1 { + let n = (bytes[i] as u32) << 16; + out.push(ALPHA[((n >> 18) & 0x3f) as usize] as char); + out.push(ALPHA[((n >> 12) & 0x3f) as usize] as char); + } else if rem == 2 { + let n = ((bytes[i] as u32) << 16) | ((bytes[i + 1] as u32) << 8); + out.push(ALPHA[((n >> 18) & 0x3f) as usize] as char); + out.push(ALPHA[((n >> 12) & 0x3f) as usize] as char); + out.push(ALPHA[((n >> 6) & 0x3f) as usize] as char); + } + out +} + +fn client_name() -> String { + let host = std::env::var("HOSTNAME") + .ok() + .or_else(|| hostname().ok()) + .unwrap_or_else(|| "unknown-host".to_string()); + format!("wavekat-cli on {host}") +} + +#[cfg(unix)] +fn hostname() -> Result { + let out = std::process::Command::new("hostname").output()?; + if !out.status.success() { + return Err(anyhow!("hostname exited non-zero")); + } + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) +} + +#[cfg(not(unix))] +fn hostname() -> Result { + std::env::var("COMPUTERNAME").map_err(|e| anyhow!(e)) +} + +fn html_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '&' => out.push_str("&"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(c), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + // RFC 4648 test vectors, adapted to URL-safe alphabet without padding. + #[test] + fn base64url_rfc_vectors() { + assert_eq!(base64url(b""), ""); + assert_eq!(base64url(b"f"), "Zg"); + assert_eq!(base64url(b"fo"), "Zm8"); + assert_eq!(base64url(b"foo"), "Zm9v"); + assert_eq!(base64url(b"foob"), "Zm9vYg"); + assert_eq!(base64url(b"fooba"), "Zm9vYmE"); + assert_eq!(base64url(b"foobar"), "Zm9vYmFy"); + } + + #[test] + fn base64url_uses_url_safe_alphabet() { + // Bytes that would yield `+` and `/` under standard base64. + // 0xfb,0xff,0xff -> 6-bit indices 62, 63, 63, 63 -> "-___". + assert_eq!(base64url(&[0xfb, 0xff, 0xff]), "-___"); + // Confirm none of the disallowed characters ever appear. + let big: Vec = (0u8..=255).collect(); + let out = base64url(&big); + assert!(!out.contains('+')); + assert!(!out.contains('/')); + assert!(!out.contains('=')); + } + + #[test] + fn random_state_shape() { + let s = random_state(); + // 24 bytes -> 32 base64url chars (no padding). + assert_eq!(s.len(), 32); + let alpha: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + for b in s.as_bytes() { + assert!(alpha.contains(b), "unexpected byte {b:#x} in state"); + } + } + + #[test] + fn random_state_is_not_constant() { + // Astronomically unlikely to collide; protects against an accidental + // hard-coded or zeroed RNG. + assert_ne!(random_state(), random_state()); + } + + #[test] + fn html_escape_handles_metacharacters() { + assert_eq!( + html_escape("it's & ok"), + "<a href="x">it's & ok</a>", + ); + assert_eq!(html_escape("plain text"), "plain text"); + assert_eq!(html_escape(""), ""); } - Ok(buf) } diff --git a/src/commands/logout.rs b/src/commands/logout.rs index d6cb2ce..ea2515b 100644 --- a/src/commands/logout.rs +++ b/src/commands/logout.rs @@ -1,12 +1,23 @@ use anyhow::Result; +use crate::client::Client; use crate::config; +use crate::style; pub async fn run() -> Result<()> { + // Best-effort server-side revoke before we forget the credentials + // locally. If the platform is unreachable we still clear the file — + // the user clearly wants to be signed out, and they can revoke from + // the web UI later. + if let Ok(client) = Client::from_config() { + let _ = client + .post_empty("/api/auth/cli/tokens/revoke-current") + .await; + } if config::clear()? { - println!("Signed out."); + println!("{} Signed out.", style::green("✓")); } else { - println!("Already signed out."); + println!("{}", style::dim("Already signed out.")); } Ok(()) } diff --git a/src/commands/me.rs b/src/commands/me.rs index 561f3bf..dee9dc9 100644 --- a/src/commands/me.rs +++ b/src/commands/me.rs @@ -2,6 +2,7 @@ use anyhow::Result; use serde::Deserialize; use crate::client::Client; +use crate::style; #[derive(Deserialize)] struct Me { @@ -15,10 +16,11 @@ struct Me { pub async fn run() -> Result<()> { let client = Client::from_config()?; let me: Me = client.get_json("/api/me").await?; - println!("login: {}", me.login); - println!("id: {}", me.id); - println!("name: {}", me.name.as_deref().unwrap_or("-")); - println!("email: {}", me.email.as_deref().unwrap_or("-")); - println!("role: {}", me.role); + let label = |s: &str| style::dim(&format!("{s:<6}")); + println!("{} {}", label("login:"), style::bold(&me.login)); + println!("{} {}", label("id:"), me.id); + println!("{} {}", label("name:"), me.name.as_deref().unwrap_or("-")); + println!("{} {}", label("email:"), me.email.as_deref().unwrap_or("-")); + println!("{} {}", label("role:"), style::role(&me.role)); Ok(()) } diff --git a/src/commands/projects.rs b/src/commands/projects.rs index 34a9506..10e5d68 100644 --- a/src/commands/projects.rs +++ b/src/commands/projects.rs @@ -3,6 +3,7 @@ use clap::{Args as ClapArgs, Subcommand}; use serde::{Deserialize, Serialize}; use crate::client::Client; +use crate::style; #[derive(Subcommand)] pub enum Cmd { @@ -27,6 +28,9 @@ pub struct ListArgs { pub struct ShowArgs { /// Project id (uuid) project_id: String, + /// Print raw JSON instead of a summary + #[arg(long)] + json: bool, } #[derive(Deserialize)] @@ -77,26 +81,69 @@ async fn list(client: &Client, args: ListArgs) -> Result<()> { println!("No projects."); return Ok(()); } - println!("{:<38} {:<28} CREATED", "ID", "NAME"); + println!( + "{} {} {}", + style::bold(&format!("{:<38}", "ID")), + style::bold(&format!("{:<28}", "NAME")), + style::bold("CREATED"), + ); for p in &resp.projects { + // Pad to fixed widths in raw bytes first, then style — ANSI escape + // codes count as bytes (not columns) inside Rust's `{: Result<()> { let path = format!("/api/projects/{}", args.project_id); let v: serde_json::Value = client.get_json(&path).await?; - println!("{}", serde_json::to_string_pretty(&v)?); + if args.json { + println!("{}", serde_json::to_string_pretty(&v)?); + return Ok(()); + } + let s = |k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or("-").to_string(); + let label = |s: &str| style::dim(&format!("{s:<13}")); + println!("{} {}", label("id:"), style::dim(&s("id"))); + println!("{} {}", label("name:"), style::bold(&s("name"))); + if let Some(desc) = v.get("description").and_then(|x| x.as_str()) { + if !desc.trim().is_empty() { + println!("{} {desc}", label("description:")); + } + } + println!("{} {}", label("created:"), s("createdAt")); + println!("{} {}", label("updated:"), s("updatedAt")); + if let Some(ls) = v.get("activeLabelSetId").and_then(|x| x.as_str()) { + println!("{} {ls}", label("label set:")); + } + if let Some(role) = v.get("role").and_then(|x| x.as_str()) { + println!("{} {}", label("your role:"), style::cyan(role)); + } Ok(()) } @@ -109,3 +156,22 @@ fn truncate(s: &str, n: usize) -> String { s.to_string() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn truncate_passes_short_strings() { + assert_eq!(truncate("hi", 28), "hi"); + assert_eq!(truncate("", 5), ""); + } + + #[test] + fn truncate_clips_long_strings_with_ellipsis() { + let out = truncate("abcdefghij", 5); + assert_eq!(out.chars().count(), 5); + assert!(out.ends_with('…')); + assert_eq!(out, "abcd…"); + } +} diff --git a/src/config.rs b/src/config.rs index 43a5de4..9b2392c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -3,10 +3,20 @@ use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; +/// Persisted credentials. The CLI now authenticates with a bearer token +/// minted via the loopback OAuth flow (see `commands::login`); the older +/// `session_cookie` field is still read so existing installs keep working +/// until the next `wk login`. #[derive(Serialize, Deserialize, Clone)] pub struct AuthConfig { pub base_url: String, - pub session_cookie: String, + /// `wkcli_…` bearer token issued by `POST /api/auth/cli/tokens`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub token: Option, + /// Legacy: raw value of the `wk_session` cookie. Read for back-compat, + /// not written by new logins. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_cookie: Option, } fn config_dir() -> Result { diff --git a/src/main.rs b/src/main.rs index 923006d..55802cb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,11 +4,25 @@ use clap::{Parser, Subcommand}; mod client; mod commands; mod config; +mod style; + +// Both `-V` and `--version` print the same string — that matches what +// `rustc -V` / `cargo -V` actually do, despite clap's default of +// splitting them. clap prepends the bin name ("wk ") for us. +const VERSION: &str = concat!( + env!("CARGO_PKG_VERSION"), + "\n", + env!("CARGO_PKG_NAME"), + " — ", + env!("CARGO_PKG_DESCRIPTION"), + "\n", + env!("CARGO_PKG_REPOSITORY"), +); #[derive(Parser)] #[command( name = "wk", - version, + version = VERSION, about = "Command-line client for the WaveKat platform", long_about = "Command-line client for the WaveKat platform.\n\nRun `wk login` to authenticate. \ Credentials are stored under your platform config dir (e.g. ~/.config/wavekat/auth.json on Linux/macOS)." diff --git a/src/style.rs b/src/style.rs new file mode 100644 index 0000000..c374ae9 --- /dev/null +++ b/src/style.rs @@ -0,0 +1,110 @@ +// Tiny ANSI styling helpers. Self-contained so we don't drag in a colour +// crate just to dim a few labels. Output is automatically plain when +// stdout isn't a TTY (so piping into `jq` / files / other tools is +// untouched) and when the `NO_COLOR` env var is set per +// . `CLICOLOR_FORCE=1` forces colour on, useful in +// CI where stdout is captured but you still want the codes. + +use std::io::IsTerminal; +use std::sync::OnceLock; + +fn enabled() -> bool { + static ON: OnceLock = OnceLock::new(); + *ON.get_or_init(|| { + if std::env::var_os("NO_COLOR").is_some() { + return false; + } + if std::env::var("CLICOLOR_FORCE").is_ok_and(|v| v != "0") { + return true; + } + std::io::stdout().is_terminal() + }) +} + +fn wrap(code: &str, body: &str) -> String { + if enabled() { + format!("\x1b[{code}m{body}\x1b[0m") + } else { + body.to_string() + } +} + +pub fn bold(s: &str) -> String { + wrap("1", s) +} +pub fn dim(s: &str) -> String { + wrap("2", s) +} +pub fn red(s: &str) -> String { + wrap("31", s) +} +pub fn green(s: &str) -> String { + wrap("32", s) +} +pub fn yellow(s: &str) -> String { + wrap("33", s) +} +pub fn cyan(s: &str) -> String { + wrap("36", s) +} +pub fn magenta(s: &str) -> String { + wrap("35", s) +} + +/// Colour a review status string. Unknown / unreviewed render dim. +pub fn review(status: Option<&str>) -> String { + match status { + Some("approved") => green("approved"), + Some("rejected") => red("rejected"), + Some("needs_fix") => yellow("needs_fix"), + Some(other) => other.to_string(), + None => dim("—"), + } +} + +/// Colour a user role string. +pub fn role(s: &str) -> String { + match s { + "root" => magenta("root"), + "user" => green("user"), + "none" => yellow("none"), + _ => s.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // These tests assert on substrings rather than exact bytes, so they + // pass whether `enabled()` decides colour is on or off — the cached + // OnceLock means we can't reliably flip the mode mid-test-binary. + + #[test] + fn bold_preserves_payload() { + assert!(bold("hello").contains("hello")); + } + + #[test] + fn review_known_states_contain_label() { + assert!(review(Some("approved")).contains("approved")); + assert!(review(Some("rejected")).contains("rejected")); + assert!(review(Some("needs_fix")).contains("needs_fix")); + } + + #[test] + fn review_unknown_passes_through() { + // Unknown statuses must render verbatim — no swallowing. + assert_eq!(review(Some("draft")), "draft"); + } + + #[test] + fn review_none_renders_em_dash() { + assert!(review(None).contains('—')); + } + + #[test] + fn role_unknown_passes_through() { + assert_eq!(role("admin"), "admin"); + } +}