diff --git a/CHANGELOG.md b/CHANGELOG.md index 895f5e07..a4d8c83a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ All notable changes to the Toolpath workspace are documented here. +## `path auth login --token` — streamlined, browser-free sign-in — 2026-07-01 + +Adds a second way to authenticate the CLI that skips the browser code +exchange entirely. The existing flow (`path auth login` → open +`/auth/cli` → copy an 8-character code → paste it back → redeem) +still works unchanged; `--token` is an alternative for when you're already +signed in on the Pathbase site, which can then hand you a single +ready-to-paste command. + +- **`path auth login --token `** (path-cli 0.15.0). The token *is* + the bearer credential — there's no redeem round-trip. The CLI validates + it against `GET /api/v1/users/me` before writing anything, so a bad or + expired token fails fast and never leaves a half-written session on disk; + on success it stores the same `credentials.json` (0600) the code flow + produces. Server URL resolves as usual (`--url` → `$PATHBASE_URL` → + `https://pathbase.dev`). +- `--token` **conflicts with** `--code`. The interactive prompt now also + points at the one-line token command for users already signed in on the + site. + ## Token usage: once per message, with per-step attribution + kind v1.1.0 — 2026-06-17 Fixes token over-counting in derived documents (~3× output-token diff --git a/CLAUDE.md b/CLAUDE.md index 01c70e9f..9b9d1733 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -134,6 +134,7 @@ cargo run -p path-cli -- show claude --project /path/to/project --session # streamlined: paste a token from Pathbase, no browser round-trip cargo run -p path-cli -- auth status cargo run -p path-cli -- auth whoami cargo run -p path-cli -- auth logout @@ -156,6 +157,14 @@ writes to `~/.toolpath/credentials.json` (0600, parent dir 0700) and sends as overrides the credentials directory. Server URL comes from `--url`, then `$PATHBASE_URL`, then `https://pathbase.dev`. +`path auth login --token ` is the streamlined alternative — no browser +code exchange. When you're already signed in on the Pathbase site it hands you +a ready-to-paste `path auth login --token ` command; the CLI validates +that token with `GET /api/v1/users/me` (so a bad/expired token fails fast and +never gets written), then stores the same `credentials.json` session the code +flow produces. `--token` conflicts with `--code`. The token *is* the bearer +credential — there's no redeem step. See `login_with_token` in `cmd_auth.rs`. + The CLI redeem endpoint (`POST /api/v1/auth/cli/redeem`) is real and works in production but is **not listed in `schema/pathbase-openapi.json`** — the OpenAPI spec only covers the documented surface. Don't be surprised that diff --git a/Cargo.lock b/Cargo.lock index 77e86508..55187785 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2308,7 +2308,7 @@ dependencies = [ [[package]] name = "path-cli" -version = "0.14.0" +version = "0.15.0" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index b03c2d21..7fbe44d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,7 +35,7 @@ toolpath-github = { version = "0.6.0", path = "crates/toolpath-github" } toolpath-dot = { version = "0.5.0", path = "crates/toolpath-dot" } toolpath-md = { version = "0.7.0", path = "crates/toolpath-md" } toolpath-pi = { version = "0.6.0", path = "crates/toolpath-pi" } -path-cli = { version = "0.14.0", path = "crates/path-cli" } +path-cli = { version = "0.15.0", path = "crates/path-cli" } pathbase-client = { version = "0.2.0", path = "crates/pathbase-client" } reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "rustls"] } diff --git a/crates/path-cli/Cargo.toml b/crates/path-cli/Cargo.toml index 01eea174..7a6fa876 100644 --- a/crates/path-cli/Cargo.toml +++ b/crates/path-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "path-cli" -version = "0.14.0" +version = "0.15.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/path-cli/src/cmd_auth.rs b/crates/path-cli/src/cmd_auth.rs index 621dbc4a..92494dc0 100644 --- a/crates/path-cli/src/cmd_auth.rs +++ b/crates/path-cli/src/cmd_auth.rs @@ -1,15 +1,15 @@ -use anyhow::{Result, anyhow}; +use anyhow::{Context, Result, anyhow, bail}; use clap::Subcommand; use std::path::Path; use crate::cmd_pathbase::{ - StoredSession, api_logout, api_me, api_redeem, clear_session, credentials_path, load_session, - prompt_line, resolve_url, store_session, + StoredSession, User, api_logout, api_me, api_redeem, clear_session, credentials_path, + load_session, prompt_line, resolve_url, store_session, }; #[derive(Subcommand, Debug)] pub enum AuthOp { - /// Log in by opening a browser to Pathbase and pasting the displayed code + /// Log in to Pathbase — paste a code from the browser, or a token Login { /// Pathbase server URL (defaults to $PATHBASE_URL or https://pathbase.dev) #[arg(long)] @@ -18,6 +18,13 @@ pub enum AuthOp { /// Paste the code directly instead of prompting #[arg(long)] code: Option, + + /// Log in with a token straight from Pathbase, skipping the browser + /// code exchange. When you're already signed in on the Pathbase site + /// it hands you a ready-to-paste `path auth login --token ` + /// command; this validates that token and stores the session. + #[arg(long, conflicts_with = "code")] + token: Option, }, /// Log out and clear the stored session Logout, @@ -30,17 +37,26 @@ pub enum AuthOp { pub fn run(op: AuthOp) -> Result<()> { let path = credentials_path()?; match op { - AuthOp::Login { url, code } => login(&path, url, code), + AuthOp::Login { url, code, token } => login(&path, url, code, token), AuthOp::Logout => logout(&path), AuthOp::Status => status(&path), AuthOp::Whoami => whoami(&path), } } -fn login(path: &Path, url: Option, code_arg: Option) -> Result<()> { +fn login( + path: &Path, + url: Option, + code_arg: Option, + token_arg: Option, +) -> Result<()> { let base_url = resolve_url(url); - let auth_url = format!("{base_url}/auth/cli"); + if let Some(raw) = token_arg { + return login_with_token(path, &base_url, raw); + } + + let auth_url = format!("{base_url}/auth/cli"); let code = match code_arg { Some(c) => c, None => { @@ -50,15 +66,41 @@ fn login(path: &Path, url: Option, code_arg: Option) -> Result<( println!(" 2. Sign in if prompted"); println!(" 3. Copy the 8-character code shown on that page"); println!(); + println!(" (already signed in there? paste the one-line"); + println!(" `path auth login --token ` command instead)"); + println!(); prompt_line("Paste code: ")? } }; let (token, user) = api_redeem(&base_url, &code)?; + finish_login(path, &base_url, token, user) +} + +/// Sign in with a token issued directly by Pathbase, no browser round-trip. +/// +/// The token is used as-is as the bearer credential; we validate it (and +/// resolve the account it belongs to) with a `GET /users/me` before writing +/// it to disk, so a bad or expired token fails here instead of silently at +/// the next upload. +fn login_with_token(path: &Path, base_url: &str, raw: String) -> Result<()> { + let token = raw.trim().to_string(); + if token.is_empty() { + bail!("--token was empty; paste the token from {base_url}/auth/cli"); + } + + let user = api_me(base_url, &token) + .with_context(|| format!("failed to sign in to {base_url} with the provided token"))?; + finish_login(path, base_url, token, user) +} + +/// Persist the session and print the "logged in as …" confirmation. Shared +/// by the code-exchange and token login flows. +fn finish_login(path: &Path, base_url: &str, token: String, user: User) -> Result<()> { store_session( path, &StoredSession { - url: base_url.clone(), + url: base_url.to_string(), token, user: user.clone(), }, diff --git a/crates/path-cli/tests/integration.rs b/crates/path-cli/tests/integration.rs index 27c53d8a..ec68aa0b 100644 --- a/crates/path-cli/tests/integration.rs +++ b/crates/path-cli/tests/integration.rs @@ -412,6 +412,103 @@ fn auth_login_against_unreachable_url_errors() { .stderr(predicate::str::contains("127.0.0.1")); } +/// One-shot mock of `GET /api/v1/users/me`. `path auth login --token` +/// validates the pasted token against this endpoint before storing it. +fn spawn_me_mock(status_line: &'static str, body: String) -> (u16, std::thread::JoinHandle<()>) { + use std::io::{Read, Write}; + use std::net::TcpListener; + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let handle = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut buf = [0u8; 4096]; + let _ = stream.read(&mut buf); + let resp = format!( + "{status_line}\r\nContent-Length: {}\r\nContent-Type: application/json\r\n\r\n{body}", + body.len() + ); + let _ = stream.write_all(resp.as_bytes()); + }); + (port, handle) +} + +/// Minimal `User` payload — the fields the generated pathbase client +/// requires to decode `/users/me` successfully. +fn me_body(username: &str) -> String { + format!( + r#"{{"id":"00000000-0000-0000-0000-000000000001","username":"{username}","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z"}}"# + ) +} + +#[test] +fn auth_login_help_mentions_token() { + cmd() + .args(["auth", "login", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--token")); +} + +#[test] +fn auth_login_token_conflicts_with_code() { + cmd() + .args(["auth", "login", "--token", "tok", "--code", "BCDFGHJK"]) + .assert() + .failure() + .stderr(predicate::str::contains("cannot be used with")); +} + +#[test] +fn auth_login_with_token_validates_and_stores_session() { + let (port, server) = spawn_me_mock("HTTP/1.1 200 OK", me_body("alice")); + let cfg = tempfile::tempdir().unwrap(); + let url = format!("http://127.0.0.1:{port}"); + + // Token login hits /users/me, resolves the account, writes credentials. + cmd() + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args(["auth", "login", "--token", "secret-token", "--url"]) + .arg(&url) + .assert() + .success() + .stdout(predicate::str::contains("Logged in to")) + .stdout(predicate::str::contains("alice")); + server.join().unwrap(); + + // The session is on disk — `status` reads it back with no network call. + cmd() + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args(["auth", "status"]) + .assert() + .success() + .stdout(predicate::str::contains("alice")) + .stdout(predicate::str::contains(&url)); +} + +#[test] +fn auth_login_with_rejected_token_errors() { + let (port, server) = spawn_me_mock("HTTP/1.1 401 Unauthorized", "{}".to_string()); + let cfg = tempfile::tempdir().unwrap(); + let url = format!("http://127.0.0.1:{port}"); + + cmd() + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args(["auth", "login", "--token", "bad-token", "--url"]) + .arg(&url) + .assert() + .failure() + .stderr(predicate::str::contains("failed to sign in")); + server.join().unwrap(); + + // A rejected token must not leave a half-written session behind. + cmd() + .env("TOOLPATH_CONFIG_DIR", cfg.path()) + .args(["auth", "status"]) + .assert() + .success() + .stdout(predicate::str::contains("Not logged in")); +} + // ── Import / export / cache ───────────────────────────────────────── #[test] diff --git a/site/_data/crates.json b/site/_data/crates.json index abf80299..c9335061 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -105,7 +105,7 @@ }, { "name": "path-cli", - "version": "0.14.0", + "version": "0.15.0", "description": "Unified CLI (binary: path)", "docs": "https://docs.rs/path-cli", "crate": "https://crates.io/crates/path-cli",