#!/usr/bin/env bash # rdev-cli — credential management CLI for the rdev platform # # Usage: rdev-cli [subcommand] [flags] # # Commands: # me Show current key identity & access # keys list List all API keys (table format) # keys get Get a specific key (JSON) # keys create --name --scopes Create a new API key # keys update [flags] Update a key # keys revoke Revoke a key (prompts confirmation) # access list List keys with access to a project # access grant Grant a key access to a project # access revoke Revoke a key's access to a project # # Required env vars: # RDEV_API_URL — e.g. https://rdev.masq-ops.orchard9.ai # RDEV_API_KEY — base64 or rdev_sk_ format set -euo pipefail # ─── colours ──────────────────────────────────────────────────────────────── RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' CYAN='\033[0;36m' BOLD='\033[1m' NC='\033[0m' # ─── env check ────────────────────────────────────────────────────────────── preflight_check() { local missing=0 if [[ -z "${RDEV_API_URL:-}" ]]; then echo -e "${RED}Error: RDEV_API_URL is not set${NC}" >&2 missing=1 fi if [[ -z "${RDEV_API_KEY:-}" ]]; then echo -e "${RED}Error: RDEV_API_KEY is not set${NC}" >&2 missing=1 fi if [[ $missing -ne 0 ]]; then echo "" >&2 echo "Set the required environment variables:" >&2 echo " export RDEV_API_URL=\"https://rdev.masq-ops.orchard9.ai\"" >&2 echo " export RDEV_API_KEY=\"\"" >&2 echo "" >&2 echo "Or source your secrets file:" >&2 echo " source ~/.zshrc" >&2 exit 1 fi } # ─── api helper ───────────────────────────────────────────────────────────── # api_call METHOD /path [body] # Prints JSON response body; exits non-zero on HTTP error. api_call() { local method="$1" local path="$2" local body="${3:-}" local tmpfile status_code response_body tmpfile=$(mktemp) if [[ -n "$body" ]]; then status_code=$(curl -s -o "$tmpfile" -w "%{http_code}" \ --max-time 30 \ -X "$method" \ -H "X-API-Key: $RDEV_API_KEY" \ -H "Content-Type: application/json" \ -d "$body" \ "${RDEV_API_URL}${path}") else status_code=$(curl -s -o "$tmpfile" -w "%{http_code}" \ --max-time 30 \ -X "$method" \ -H "X-API-Key: $RDEV_API_KEY" \ "${RDEV_API_URL}${path}") fi response_body=$(cat "$tmpfile") rm -f "$tmpfile" if [[ "$status_code" -lt 200 || "$status_code" -ge 300 ]]; then echo -e "${RED}Error: HTTP $status_code${NC}" >&2 if [[ -n "$response_body" ]]; then echo "$response_body" | jq -r '.message // .error // .' 2>/dev/null >&2 || echo "$response_body" >&2 fi exit 1 fi echo "$response_body" } # ─── me ───────────────────────────────────────────────────────────────────── cmd_me() { local resp resp=$(api_call GET "/me") local id name prefix scopes project_access expires_at active id=$(echo "$resp" | jq -r '.data.id // .id') name=$(echo "$resp" | jq -r '.data.name // .name') prefix=$(echo "$resp" | jq -r '.data.key_prefix // .key_prefix') scopes=$(echo "$resp" | jq -r '(.data.scopes // .scopes) | join(", ")') project_access=$(echo "$resp" | jq -r '.data.project_access // .project_access') expires_at=$(echo "$resp" | jq -r '.data.expires_at // .expires_at // "never"') active=$(echo "$resp" | jq -r '.data.active // .active') echo "" echo -e "${BOLD}Current key identity${NC}" echo "────────────────────────────────────" printf " %-16s %s\n" "ID:" "$id" printf " %-16s %s\n" "Name:" "$name" printf " %-16s %s\n" "Prefix:" "$prefix" printf " %-16s %s\n" "Scopes:" "$scopes" printf " %-16s %s\n" "Access:" "$project_access" printf " %-16s %s\n" "Expires:" "$expires_at" printf " %-16s %s\n" "Active:" "$active" # Show project list if restricted local projects projects=$(echo "$resp" | jq -r '(.data.projects // .projects) // []') local project_count project_count=$(echo "$projects" | jq 'length') if [[ "$project_count" -gt 0 ]]; then echo "" echo " Projects:" echo "$projects" | jq -r '.[] | " - \(.id) \(.name) [\(.status)]"' fi # Show allowed IPs if set local allowed_ips allowed_ips=$(echo "$resp" | jq -r '(.data.allowed_ips // .allowed_ips) // []') local ip_count ip_count=$(echo "$allowed_ips" | jq 'length') if [[ "$ip_count" -gt 0 ]]; then echo "" echo " Allowed IPs:" echo "$allowed_ips" | jq -r '.[] | " - \(.)"' fi echo "" } # ─── keys ─────────────────────────────────────────────────────────────────── cmd_keys_list() { local resp resp=$(api_call GET "/keys") local keys keys=$(echo "$resp" | jq -r '.data // .') local count count=$(echo "$keys" | jq 'length') if [[ "$count" -eq 0 ]]; then echo "No API keys found." return fi echo "" printf "${BOLD}%-8s %-30s %-36s %-30s %-12s %-6s${NC}\n" \ "PREFIX" "NAME" "ID" "SCOPES" "EXPIRES" "ACTIVE" printf '%s\n' "────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────" echo "$keys" | jq -r '.[] | [ .key_prefix, .name, .id, (.scopes | join(",")), (.expires_at // "never"), (if .active then "yes" else "no" end) ] | @tsv' | while IFS=$'\t' read -r prefix name id scopes expires active; do # Truncate long fields for readability local name_trunc="${name:0:29}" local scopes_trunc="${scopes:0:29}" local active_color="$GREEN" [[ "$active" == "no" ]] && active_color="$RED" printf "%-8s %-30s %-36s %-30s %-12s ${active_color}%-6s${NC}\n" \ "$prefix" "$name_trunc" "$id" "$scopes_trunc" "${expires:0:10}" "$active" done echo "" } cmd_keys_get() { local id="${1:-}" if [[ -z "$id" ]]; then echo -e "${RED}Error: key id required${NC}" >&2 echo "Usage: rdev-cli keys get " >&2 exit 1 fi api_call GET "/keys/$id" | jq . } cmd_keys_create() { local name="" scopes="" expires_in="90d" project_ids_raw="" allowed_ips_raw="" # Parse flags while [[ $# -gt 0 ]]; do case "$1" in --name) name="$2"; shift 2 ;; --scopes) scopes="$2"; shift 2 ;; --expires) expires_in="$2"; shift 2 ;; --project-ids) project_ids_raw="$2"; shift 2 ;; --allowed-ips) allowed_ips_raw="$2"; shift 2 ;; *) echo -e "${RED}Error: unknown flag: $1${NC}" >&2; exit 1 ;; esac done if [[ -z "$name" ]]; then echo -e "${RED}Error: --name is required${NC}" >&2 echo "Usage: rdev-cli keys create --name --scopes " >&2 exit 1 fi if [[ -z "$scopes" ]]; then echo -e "${RED}Error: --scopes is required${NC}" >&2 echo "Usage: rdev-cli keys create --name --scopes " >&2 exit 1 fi # Convert comma-separated scopes → JSON array local scopes_json scopes_json=$(echo "$scopes" | tr ',' '\n' | jq -R . | jq -s .) # Convert comma-separated project IDs → JSON array or null local project_ids_json="null" if [[ -n "$project_ids_raw" && "$project_ids_raw" != "null" && "$project_ids_raw" != '""' ]]; then project_ids_json=$(echo "$project_ids_raw" | tr ',' '\n' | jq -R . | jq -s .) fi # Convert comma-separated allowed IPs → JSON array local allowed_ips_json="[]" if [[ -n "$allowed_ips_raw" ]]; then allowed_ips_json=$(echo "$allowed_ips_raw" | tr ',' '\n' | jq -R . | jq -s .) fi # Build request body local body body=$(jq -n \ --arg name "$name" \ --argjson scopes "$scopes_json" \ --argjson pids "$project_ids_json" \ --arg expires "$expires_in" \ --argjson ips "$allowed_ips_json" \ '{ name: $name, scopes: $scopes, project_ids: $pids, expires_in: $expires, allowed_ips: $ips }') local resp resp=$(api_call POST "/keys" "$body") # Extract fields (handles both wrapped .data and flat response) local key_id key_name key_scopes key_secret key_id=$(echo "$resp" | jq -r '(.data.key.id // .key.id // .id)') key_name=$(echo "$resp" | jq -r '(.data.key.name // .key.name // .name)') key_scopes=$(echo "$resp" | jq -r '((.data.key.scopes // .key.scopes // .scopes) | join(", "))') key_secret=$(echo "$resp" | jq -r '(.data.secret // .secret)') # Secret box echo "" echo -e "${YELLOW}╔══════════════════════════════════════════════════════════════╗" echo "║ NEW API KEY — SAVE THIS SECRET NOW — SHOWN ONCE ║" echo "║ ║" printf "║ %-10s %-49s║\n" "ID:" "$key_id" printf "║ %-10s %-49s║\n" "Name:" "$key_name" printf "║ %-10s %-49s║\n" "Scopes:" "${key_scopes:0:48}" printf "║ %-10s %-49s║\n" "Secret:" "$key_secret" echo "║ ║" echo "║ Add to ~/.zshrc or secrets manager before continuing. ║" echo -e "╚══════════════════════════════════════════════════════════════╝${NC}" echo "" read -r -p "Press [enter] when you have saved the secret..." echo "" echo -e "${GREEN}✓ Key created — ${key_name} (${key_id:0:8}...)${NC}" echo "" } cmd_keys_update() { local id="${1:-}" if [[ -z "$id" ]]; then echo -e "${RED}Error: key id required${NC}" >&2 echo "Usage: rdev-cli keys update [--name ] [--scopes ] [--expires ] [--project-ids ] [--allowed-ips ]" >&2 exit 1 fi shift local name="" scopes="" expires_in="" project_ids_raw="" allowed_ips_raw="" local has_name=0 has_scopes=0 has_expires=0 has_pids=0 has_ips=0 while [[ $# -gt 0 ]]; do case "$1" in --name) name="$2"; has_name=1; shift 2 ;; --scopes) scopes="$2"; has_scopes=1; shift 2 ;; --expires) expires_in="$2"; has_expires=1; shift 2 ;; --project-ids) project_ids_raw="$2"; has_pids=1; shift 2 ;; --allowed-ips) allowed_ips_raw="$2"; has_ips=1; shift 2 ;; *) echo -e "${RED}Error: unknown flag: $1${NC}" >&2; exit 1 ;; esac done # Build partial update body using jq null-safe approach local body="{}" if [[ $has_name -eq 1 ]]; then body=$(echo "$body" | jq --arg v "$name" '. + {name: $v}') fi if [[ $has_scopes -eq 1 ]]; then local scopes_json scopes_json=$(echo "$scopes" | tr ',' '\n' | jq -R . | jq -s .) body=$(echo "$body" | jq --argjson v "$scopes_json" '. + {scopes: $v}') fi if [[ $has_expires -eq 1 ]]; then body=$(echo "$body" | jq --arg v "$expires_in" '. + {expires_in: $v}') fi if [[ $has_pids -eq 1 ]]; then if [[ "$project_ids_raw" == "null" || -z "$project_ids_raw" ]]; then body=$(echo "$body" | jq '. + {project_ids: null}') else local pids_json pids_json=$(echo "$project_ids_raw" | tr ',' '\n' | jq -R . | jq -s .) body=$(echo "$body" | jq --argjson v "$pids_json" '. + {project_ids: $v}') fi fi if [[ $has_ips -eq 1 ]]; then if [[ "$allowed_ips_raw" == "null" || -z "$allowed_ips_raw" ]]; then body=$(echo "$body" | jq '. + {allowed_ips: null}') else local ips_json ips_json=$(echo "$allowed_ips_raw" | tr ',' '\n' | jq -R . | jq -s .) body=$(echo "$body" | jq --argjson v "$ips_json" '. + {allowed_ips: $v}') fi fi local resp resp=$(api_call PATCH "/keys/$id" "$body") local key_name key_prefix key_name=$(echo "$resp" | jq -r '.data.name // .name') key_prefix=$(echo "$resp" | jq -r '.data.key_prefix // .key_prefix') echo -e "${GREEN}✓ Updated — ${key_name} (${key_prefix}...)${NC}" } cmd_keys_revoke() { local id="${1:-}" if [[ -z "$id" ]]; then echo -e "${RED}Error: key id required${NC}" >&2 echo "Usage: rdev-cli keys revoke " >&2 exit 1 fi # Fetch key details for confirmation local key_resp key_resp=$(api_call GET "/keys/$id") local key_name key_prefix active key_name=$(echo "$key_resp" | jq -r '.data.name // .name') key_prefix=$(echo "$key_resp" | jq -r '.data.key_prefix // .key_prefix') active=$(echo "$key_resp" | jq -r '.data.active // .active') echo "" echo -e "${YELLOW}About to revoke:${NC}" echo " Name: $key_name" echo " ID: $id" echo " Prefix: $key_prefix" echo " Active: $active" echo "" local confirm read -r -p "Revoke? [y/N] " confirm if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then echo "Aborted." exit 0 fi api_call DELETE "/keys/$id" > /dev/null echo -e "${GREEN}✓ Revoked — ${key_name} (${key_prefix}...)${NC}" echo "" } # ─── access ───────────────────────────────────────────────────────────────── cmd_access_list() { local project_id="${1:-}" if [[ -z "$project_id" ]]; then echo -e "${RED}Error: project-id required${NC}" >&2 echo "Usage: rdev-cli access list " >&2 exit 1 fi local resp resp=$(api_call GET "/projects/$project_id/access") local unrestricted_count unrestricted_count=$(echo "$resp" | jq -r '.data.unrestricted_keys // .unrestricted_keys // 0') echo "" echo -e "${BOLD}Access for project: $project_id${NC}" echo " Unrestricted keys (access all projects): $unrestricted_count" echo "" local keys keys=$(echo "$resp" | jq -r '.data.keys // .keys // []') local key_count key_count=$(echo "$keys" | jq 'length') if [[ "$key_count" -eq 0 ]]; then echo " No keys explicitly granted access to this project." else echo " Explicitly granted keys:" printf " ${BOLD}%-8s %-30s %-36s %-6s${NC}\n" "PREFIX" "NAME" "ID" "ACTIVE" printf ' %s\n' "────────────────────────────────────────────────────────────────────────" echo "$keys" | jq -r '.[] | [.key_prefix, .name, .id, (if .active then "yes" else "no" end)] | @tsv' \ | while IFS=$'\t' read -r prefix name id active; do local active_color="$GREEN" [[ "$active" == "no" ]] && active_color="$RED" printf " %-8s %-30s %-36s ${active_color}%-6s${NC}\n" \ "$prefix" "${name:0:29}" "$id" "$active" done fi echo "" } cmd_access_grant() { local project_id="${1:-}" key_id="${2:-}" if [[ -z "$project_id" || -z "$key_id" ]]; then echo -e "${RED}Error: project-id and key-id required${NC}" >&2 echo "Usage: rdev-cli access grant " >&2 exit 1 fi local body body=$(jq -n --arg kid "$key_id" '{key_id: $kid}') local resp resp=$(api_call POST "/projects/$project_id/access" "$body") local status status=$(echo "$resp" | jq -r '.data.status // .status') if [[ "$status" == "already_granted" ]]; then echo -e "${YELLOW}✓ Already granted — key ${key_id:0:8}... already has access to $project_id${NC}" else echo -e "${GREEN}✓ Granted — key ${key_id:0:8}... now has access to $project_id${NC}" fi } cmd_access_revoke() { local project_id="${1:-}" key_id="${2:-}" if [[ -z "$project_id" || -z "$key_id" ]]; then echo -e "${RED}Error: project-id and key-id required${NC}" >&2 echo "Usage: rdev-cli access revoke " >&2 exit 1 fi api_call DELETE "/projects/$project_id/access/$key_id" > /dev/null echo -e "${GREEN}✓ Revoked — key ${key_id:0:8}... no longer has access to $project_id${NC}" } # ─── help ─────────────────────────────────────────────────────────────────── cmd_help() { cat <<'EOF' rdev-cli — rdev credential management CLI Usage: rdev-cli me Show current key identity & access rdev-cli keys list List all API keys (table format) rdev-cli keys get Get a specific key (JSON) rdev-cli keys create --name --scopes Create a new API key rdev-cli keys update [flags] Update a key rdev-cli keys revoke Revoke a key (prompts confirmation) rdev-cli access list List keys with access to a project rdev-cli access grant Grant a key access to a project rdev-cli access revoke Revoke a key's access to a project Create / Update flags: --name Key name (required on create) --scopes Comma-separated scopes (required on create) --expires <30d|60d|90d|1y|never> Expiration (default: 90d on create) --project-ids Restrict to projects (null = unrestricted) --allowed-ips Restrict to IP ranges (empty = unrestricted) Scopes: projects:read projects:execute keys:read keys:write audit:read queue:read queue:write webhooks:read webhooks:write workers:read workers:write builds:read builds:write verify:read verify:write sessions:read sessions:execute admin Required env vars: RDEV_API_URL e.g. https://rdev.masq-ops.orchard9.ai RDEV_API_KEY your API key (base64 or rdev_sk_ format) EOF } # ─── router ───────────────────────────────────────────────────────────────── main() { local cmd="${1:-}" case "$cmd" in me) preflight_check cmd_me ;; keys) preflight_check local sub="${2:-}" shift 2 2>/dev/null || shift 1 2>/dev/null || true case "$sub" in list) cmd_keys_list ;; get) cmd_keys_get "$@" ;; create) cmd_keys_create "$@" ;; update) cmd_keys_update "$@" ;; revoke) cmd_keys_revoke "$@" ;; *) echo -e "${RED}Error: unknown keys subcommand: ${sub:-}${NC}" >&2 echo "Run 'rdev-cli --help' for usage." >&2 exit 1 ;; esac ;; access) preflight_check local sub="${2:-}" shift 2 2>/dev/null || shift 1 2>/dev/null || true case "$sub" in list) cmd_access_list "$@" ;; grant) cmd_access_grant "$@" ;; revoke) cmd_access_revoke "$@" ;; *) echo -e "${RED}Error: unknown access subcommand: ${sub:-}${NC}" >&2 echo "Run 'rdev-cli --help' for usage." >&2 exit 1 ;; esac ;; help|--help|-h|"") cmd_help ;; *) echo -e "${RED}Error: unknown command: $cmd${NC}" >&2 echo "Run 'rdev-cli --help' for usage." >&2 exit 1 ;; esac } main "$@"