diff --git a/.github/workflows/crates-io.yaml b/.github/workflows/crates-io.yaml new file mode 100644 index 0000000..40a846a --- /dev/null +++ b/.github/workflows/crates-io.yaml @@ -0,0 +1,87 @@ +name: crates-io + +on: + push: + tags: + - v*.*.* + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + publish: + runs-on: ubuntu-latest + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + steps: + - name: checkout + uses: actions/checkout@v5 + + - name: set up rust + uses: dtolnay/rust-toolchain@stable + + - name: verify tag matches crate version + shell: bash + run: | + set -euo pipefail + expected="${GITHUB_REF_NAME#v}" + actual="$(cargo pkgid -p ferriskey | sed 's/.*#.*://')" + if [[ "$expected" != "$actual" ]]; then + echo "tag version '$expected' does not match Cargo version '$actual'" >&2 + exit 1 + fi + + - name: run checks + run: | + cargo test --workspace + cargo clippy --workspace --all-targets --all-features -- -D warnings + + - name: publish crates + shell: bash + run: | + set -euo pipefail + + publish_if_needed() { + local crate="$1" + local version="$2" + + if curl -fsS "https://crates.io/api/v1/crates/${crate}/${version}" >/dev/null 2>&1; then + echo "${crate}@${version} already published" + return 0 + fi + + cargo publish -p "${crate}" --locked + } + + wait_for_crate() { + local crate="$1" + local version="$2" + + for _ in $(seq 1 30); do + if curl -fsS "https://crates.io/api/v1/crates/${crate}/${version}" >/dev/null 2>&1; then + return 0 + fi + sleep 10 + done + + echo "crate ${crate}@${version} was not visible on crates.io in time" >&2 + exit 1 + } + + version="${GITHUB_REF_NAME#v}" + + publish_if_needed ferriskey-commands "${version}" + wait_for_crate ferriskey-commands "${version}" + + publish_if_needed ferriskey-client "${version}" + wait_for_crate ferriskey-client "${version}" + + publish_if_needed ferriskey-cli-core "${version}" + wait_for_crate ferriskey-cli-core "${version}" + + publish_if_needed ferriskey "${version}" diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index f60a38c..b3f8cea 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -5,6 +5,9 @@ on: branches: [ main ] pull_request: branches: [ main ] + tags: + - v*.*.* + - v*.*.*-rc* workflow_dispatch: inputs: push: diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e662ab9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,46 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +cargo build --release # Build all workspace crates +cargo test --workspace # Run all tests +cargo clippy --workspace --all-targets --all-features -- -D warnings # Lint (warnings are errors) +cargo test -p ferriskey-cli-core context::tests # Run a specific test module +``` + +## Architecture + +This is a Cargo workspace with 4 crates: + +- **`ferriskey`** (root) — Binary entry point. Parses CLI with `Cli::parse()`, passes to `ferriskey_cli_core::run()`. +- **`libs/ferriskey-commands`** — Clap derive structs only. Defines `Cli`, `Commands` enum, and per-command `*Command`/`*Args` structs. No logic. +- **`libs/ferriskey-cli-core`** — Command dispatch and execution. `run()` matches on `Commands` variants and delegates to module handlers (`context.rs`, `client.rs`). Owns config management. +- **`libs/ferriskey-client`** — `reqwest`-based HTTP client. `FerriskeyClient::new(base_url, prefix, token)` handles auth (Bearer token), request serialization, and response parsing. + +### Data flow + +``` +CLI args → Cli::parse() → ferriskey_cli_core::run() + → match Commands → handler (context/client) + → FerriskeyClient → REST API + → FileContextRepository → TOML config file +``` + +### Config storage + +Contexts (URL, client_id, client_secret, optional realm) are stored as TOML at `$XDG_CONFIG_HOME/ferriskey/config.toml`. `FileContextRepository` does atomic writes (temp file + rename). `ContextStore` holds the map and tracks the active context. + +### Output formatting + +Root-level `--output` / `-o` flag accepts `table` (default), `json`, or `yaml`. Each handler has format-specific render functions. + +### Unimplemented stubs + +Realm and User commands are defined in `ferriskey-commands` but return `UnimplementedCommand` errors in `ferriskey-cli-core`. Client `get`/`delete` subcommands are also stubs. + +### OAuth2 token exchange + +`FerriskeyClient::exchange_client_credentials()` performs the client credentials flow before API calls that require authentication. diff --git a/Cargo.toml b/Cargo.toml index 2b64353..81988b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,10 @@ name = "ferriskey" version.workspace = true authors.workspace = true edition.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "Official FerrisKey CLI" [workspace] members = ["libs/ferriskey-client", "libs/ferriskey-commands", "libs/ferriskey-cli-core"] @@ -12,8 +16,11 @@ resolver = "2" version = "0.1.0" authors = ["nathaelb "] edition = "2024" +license = "Apache-2.0" +repository = "https://github.com/ferriskey/ferriskey/tree/main/cli" +homepage = "https://ferriskey.rs" [dependencies] clap = { version = "4.5", features = ["derive"] } -ferriskey-commands = { path = "libs/ferriskey-commands" } -ferriskey-cli-core = { path = "libs/ferriskey-cli-core" } +ferriskey-commands = { path = "libs/ferriskey-commands", version = "0.1.0" } +ferriskey-cli-core = { path = "libs/ferriskey-cli-core", version = "0.1.0" } diff --git a/Dockerfile b/Dockerfile index 27fe40d..334b996 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,37 +1,24 @@ -FROM rust:1.91.1-bookworm AS rust-build +FROM rust:1.95.0-bookworm AS chef WORKDIR /usr/local/src/ferriskey -COPY Cargo.toml Cargo.lock ./ -COPY libs/ferriskey-cli-core/Cargo.toml ./libs/ferriskey-cli-core/ -COPY libs/ferriskey-client/Cargo.toml ./libs/ferriskey-client/ -COPY libs/ferriskey-commands/Cargo.toml ./libs/ferriskey-commands/ +RUN cargo install cargo-chef --version 0.1.77 --locked +FROM chef AS planner +COPY . . +RUN cargo chef prepare --recipe-path recipe.json -RUN \ - mkdir -p src libs/ferriskey-cli-core/src libs/ferriskey-client/src libs/ferriskey-commands/src && \ - touch libs/ferriskey-cli-core/src/lib.rs && \ - touch libs/ferriskey-client/src/lib.rs && \ - touch libs/ferriskey-commands/src/lib.rs && \ - echo "fn main() {}" > src/main.rs && \ - cargo build --release - -COPY libs/ferriskey-cli-core libs/ferriskey-cli-core -COPY libs/ferriskey-client libs/ferriskey-client -COPY libs/ferriskey-commands libs/ferriskey-commands +FROM chef AS builder -COPY src src +COPY --from=planner /usr/local/src/ferriskey/recipe.json recipe.json +RUN cargo chef cook --release --recipe-path recipe.json -RUN \ +COPY . . +RUN cargo build --release - touch libs/ferriskey-cli-core/src/lib.rs && \ - touch libs/ferriskey-client/src/lib.rs && \ - touch libs/ferriskey-commands/src/lib.rs && \ - touch src/main.rs && \ - cargo build --release -FROM debian:bookworm-slim AS cli +FROM debian:bookworm-slim AS runtime RUN \ apt-get update && \ @@ -53,10 +40,8 @@ RUN \ USER ferriskey -FROM runtime AS api - -COPY --from=rust-build /usr/local/src/ferriskey/target/release/ferriskey /usr/local/bin/ +FROM runtime AS cli -EXPOSE 80 +COPY --from=builder /usr/local/src/ferriskey/target/release/ferriskey /usr/local/bin/ ENTRYPOINT ["ferriskey"] diff --git a/libs/ferriskey-cli-core/Cargo.toml b/libs/ferriskey-cli-core/Cargo.toml index 3063e2b..020a15a 100644 --- a/libs/ferriskey-cli-core/Cargo.toml +++ b/libs/ferriskey-cli-core/Cargo.toml @@ -3,13 +3,17 @@ name = "ferriskey-cli-core" version.workspace = true authors.workspace = true edition.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "Application runtime for the FerrisKey CLI" [lib] doctest = false [dependencies] -ferriskey-client = { path = "../ferriskey-client" } -ferriskey-commands = { path = "../ferriskey-commands" } +ferriskey-client = { path = "../ferriskey-client", version = "0.1.0" } +ferriskey-commands = { path = "../ferriskey-commands", version = "0.1.0" } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde_yaml = "0.9" diff --git a/libs/ferriskey-cli-core/src/client.rs b/libs/ferriskey-cli-core/src/client.rs index 966c160..90ff945 100644 --- a/libs/ferriskey-cli-core/src/client.rs +++ b/libs/ferriskey-cli-core/src/client.rs @@ -1,8 +1,9 @@ use ferriskey_client::{ - ClientRepresentation, CreateClientRequest, CreatedClient, FerriskeyClient, FerriskeyClientError, + ClientRepresentation, CreateClientRequest, CreatedClient, FerriskeyClient, + FerriskeyClientError, }; use ferriskey_commands::{ - ClientCommand, ClientCreateArgs, ClientListArgs, ClientSubcommand, ClientType, + ClientCommand, ClientCreateArgs, ClientGetArgs, ClientListArgs, ClientSubcommand, ClientType, }; use serde::Serialize; use thiserror::Error; @@ -14,12 +15,19 @@ type Result = std::result::Result; pub fn run( output_format: &str, context_override: Option<&str>, + inline_context: Option, command: ClientCommand, ) -> Result<()> { match command.command { - ClientSubcommand::List(args) => list_clients(output_format, context_override, args), - ClientSubcommand::Get(_) => Err(ClientCommandError::Unimplemented("client get")), - ClientSubcommand::Create(args) => create_client(output_format, context_override, args), + ClientSubcommand::List(args) => { + list_clients(output_format, context_override, inline_context, args) + } + ClientSubcommand::Get(args) => { + get_client(output_format, context_override, inline_context, args) + } + ClientSubcommand::Create(args) => { + create_client(output_format, context_override, inline_context, args) + } ClientSubcommand::Delete(_) => Err(ClientCommandError::Unimplemented("client delete")), } } @@ -32,6 +40,8 @@ pub enum ClientCommandError { Api(#[from] FerriskeyClientError), #[error("context '{0}' does not exist")] ContextNotFound(String), + #[error("client '{0}' not found")] + ClientNotFound(String), #[error("no active context is configured")] NoActiveContext, #[error( @@ -61,6 +71,19 @@ struct ClientView { name: String, } +#[derive(Debug, Serialize, PartialEq, Eq)] +struct ClientDetailView { + id: String, + client_id: String, + name: String, + realm: String, + enabled: bool, + protocol: String, + public_client: bool, + service_accounts_enabled: bool, + direct_access_grants_enabled: bool, +} + #[derive(Debug, Serialize, PartialEq, Eq)] struct CreatedClientView { id: String, @@ -75,14 +98,35 @@ struct CreatedClientView { protocol: String, } +fn get_client( + output_format: &str, + context_override: Option<&str>, + inline_context: Option, + args: ClientGetArgs, +) -> Result<()> { + let context = resolve_context(context_override, inline_context)?; + let realm = resolve_realm(&context, args.realm.clone())?; + let auth_client = FerriskeyClient::new(context.url.clone(), "", "")?; + let token = auth_client.exchange_client_credentials( + realm.as_str(), + context.client_id.as_str(), + context.client_secret.as_str(), + )?; + let client = FerriskeyClient::new(context.url, "", token.access_token)?; + let result = client + .get_client(&realm, &args.client_id)? + .ok_or_else(|| ClientCommandError::ClientNotFound(args.client_id.clone()))?; + + render_client_detail(output_format, to_detail_view(result, realm)) +} + fn list_clients( output_format: &str, context_override: Option<&str>, + inline_context: Option, args: ClientListArgs, ) -> Result<()> { - let repository = FileContextRepository::new()?; - let store = repository.load()?; - let context = select_context(&store, context_override)?; + let context = resolve_context(context_override, inline_context)?; let realm = resolve_realm(&context, args.realm.clone())?; let auth_client = FerriskeyClient::new(context.url.clone(), "", "")?; let token = auth_client.exchange_client_credentials( @@ -100,11 +144,10 @@ fn list_clients( fn create_client( output_format: &str, context_override: Option<&str>, + inline_context: Option, args: ClientCreateArgs, ) -> Result<()> { - let repository = FileContextRepository::new()?; - let store = repository.load()?; - let context = select_context(&store, context_override)?; + let context = resolve_context(context_override, inline_context)?; let realm = resolve_realm(&context, args.realm.clone())?; let auth_client = FerriskeyClient::new(context.url.clone(), "", "")?; let token = auth_client.exchange_client_credentials( @@ -119,6 +162,18 @@ fn create_client( render_created_client(output_format, to_created_view(created, realm, request)) } +fn resolve_context( + context_override: Option<&str>, + inline_context: Option, +) -> Result { + if let Some(ctx) = inline_context { + return Ok(ctx); + } + let repository = FileContextRepository::new()?; + let store = repository.load()?; + select_context(&store, context_override) +} + fn select_context(store: &ContextStore, context_override: Option<&str>) -> Result { let context_name = match context_override { Some(name) => name.to_owned(), @@ -149,6 +204,59 @@ fn to_view(client: ClientRepresentation) -> ClientView { } } +fn to_detail_view(client: ClientRepresentation, realm: String) -> ClientDetailView { + ClientDetailView { + id: client.id.unwrap_or_default(), + client_id: client.client_id.unwrap_or_default(), + name: client.name.unwrap_or_default(), + realm, + enabled: client.enabled.unwrap_or(false), + protocol: client.protocol.unwrap_or_default(), + public_client: client.public_client.unwrap_or(false), + service_accounts_enabled: client.service_accounts_enabled.unwrap_or(false), + direct_access_grants_enabled: client.direct_access_grants_enabled.unwrap_or(false), + } +} + +fn render_client_detail(output_format: &str, client: ClientDetailView) -> Result<()> { + match output_format { + "table" => { + println!("id: {}", client.id); + println!("client_id: {}", client.client_id); + println!("name: {}", client.name); + println!("realm: {}", client.realm); + println!("enabled: {}", client.enabled); + println!("protocol: {}", client.protocol); + println!("public_client: {}", client.public_client); + println!("service_accounts_enabled: {}", client.service_accounts_enabled); + println!( + "direct_access_grants_enabled: {}", + client.direct_access_grants_enabled + ); + Ok(()) + } + "json" => { + println!( + "{}", + serde_json::to_string_pretty(&client) + .map_err(|source| ClientCommandError::SerializeJson { source })? + ); + Ok(()) + } + "yaml" => { + println!( + "{}", + serde_yaml::to_string(&client) + .map_err(|source| ClientCommandError::SerializeYaml { source })? + ); + Ok(()) + } + _ => Err(ClientCommandError::UnsupportedOutputFormat( + output_format.to_owned(), + )), + } +} + fn build_create_client_request(args: ClientCreateArgs) -> CreateClientRequest { let client_id = args.client_id.unwrap_or_else(|| args.name.clone()); let (client_type, public_client, service_account_enabled) = diff --git a/libs/ferriskey-cli-core/src/lib.rs b/libs/ferriskey-cli-core/src/lib.rs index 471c4a3..cb09b7c 100644 --- a/libs/ferriskey-cli-core/src/lib.rs +++ b/libs/ferriskey-cli-core/src/lib.rs @@ -2,6 +2,7 @@ mod client; mod config; mod context; +use config::StoredContext; use ferriskey_commands::{Cli, Commands}; use thiserror::Error; @@ -18,14 +19,28 @@ pub enum CliCoreError { } pub fn run(cli: Cli) -> Result<()> { + let inline_context = build_inline_context(&cli); match cli.command { Commands::Context(command) => Ok(context::run(cli.output.as_str(), command)?), Commands::Realm(_) => Err(CliCoreError::UnimplementedCommand("realm")), Commands::Client(command) => Ok(client::run( cli.output.as_str(), cli.context.as_deref(), + inline_context, command, )?), Commands::User(_) => Err(CliCoreError::UnimplementedCommand("user")), } } + +fn build_inline_context(cli: &Cli) -> Option { + match (&cli.url, &cli.client_id, &cli.client_secret) { + (Some(url), Some(client_id), Some(client_secret)) => Some(StoredContext { + url: url.clone(), + client_id: client_id.clone(), + client_secret: client_secret.clone(), + realm: cli.realm.clone(), + }), + _ => None, + } +} diff --git a/libs/ferriskey-commands/Cargo.toml b/libs/ferriskey-commands/Cargo.toml index ff037c0..53c0934 100644 --- a/libs/ferriskey-commands/Cargo.toml +++ b/libs/ferriskey-commands/Cargo.toml @@ -3,9 +3,13 @@ name = "ferriskey-commands" version.workspace = true authors.workspace = true edition.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "Clap command definitions for the FerrisKey CLI" [lib] doctest = false [dependencies] -clap = { version = "4.5", features = ["derive"] } +clap = { version = "4.5", features = ["derive", "env"] } diff --git a/libs/ferriskey-commands/src/client.rs b/libs/ferriskey-commands/src/client.rs index c1db77d..d39c94c 100644 --- a/libs/ferriskey-commands/src/client.rs +++ b/libs/ferriskey-commands/src/client.rs @@ -35,9 +35,9 @@ pub struct ClientGetArgs { /// Client identifier. pub client_id: String, - /// Realm name. + /// Realm name. Defaults to the selected context realm. #[arg(long)] - pub realm: String, + pub realm: Option, } /// Arguments for creating a client. @@ -88,7 +88,7 @@ pub struct ClientDeleteArgs { /// Client identifier. pub client_id: String, - /// Realm name. + /// Realm name. Defaults to the selected context realm. #[arg(long)] - pub realm: String, + pub realm: Option, } diff --git a/libs/ferriskey-commands/src/lib.rs b/libs/ferriskey-commands/src/lib.rs index 7b80ef4..6c7761a 100644 --- a/libs/ferriskey-commands/src/lib.rs +++ b/libs/ferriskey-commands/src/lib.rs @@ -4,7 +4,7 @@ mod realm; mod user; pub use self::client::{ - ClientCommand, ClientCreateArgs, ClientListArgs, ClientSubcommand, ClientType, + ClientCommand, ClientCreateArgs, ClientGetArgs, ClientListArgs, ClientSubcommand, ClientType, }; pub use self::context::{ ContextAddArgs, ContextCommand, ContextRemoveArgs, ContextSubcommand, ContextUseArgs, @@ -23,6 +23,22 @@ pub struct Cli { #[arg(long, short = 'o', value_parser = ["table", "json", "yaml"], default_value = "table")] pub output: String, + /// FerrisKey server URL (overrides context file). + #[arg(long, env = "FERRISKEY_URL")] + pub url: Option, + + /// Client ID used for authentication (overrides context file). + #[arg(long, env = "FERRISKEY_CLIENT_ID")] + pub client_id: Option, + + /// Client secret used for authentication (overrides context file). + #[arg(long, env = "FERRISKEY_CLIENT_SECRET")] + pub client_secret: Option, + + /// Default realm (overrides context file). + #[arg(long, env = "FERRISKEY_REALM")] + pub realm: Option, + /// Command to execute. #[command(subcommand)] pub command: Commands,