rdev/scripts/rdev-cli.sh
jordan 96219a647f
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
feat: add POST /projects/{id}/notify/reprovision to migrate notify host
Implements ReprovisionNotifyHost to migrate a project's email sending
from an old notify host to a new one (e.g., from project-name-based to
slug-based host). Preserves the project's notify account and send key.

- Adds ReprovisionNotifyHost to port.NotifyProvisioner interface
- Implements revokeHostAccess on notifyAdminAPI + adminClient
- Implements Provisioner.ReprovisionNotifyHost (12-step migration)
  in provisioner_reprovision.go (split to keep provisioner.go < 500 lines)
- Adds NotifyHandler.Reprovision handler (POST /notify/reprovision)
- Updates OpenAPI spec with reprovision endpoint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 21:28:59 -07:00

560 lines
21 KiB
Bash
Executable File

#!/usr/bin/env bash
# rdev-cli — credential management CLI for the rdev platform
#
# Usage: rdev-cli <command> [subcommand] [flags]
#
# Commands:
# me Show current key identity & access
# keys list List all API keys (table format)
# keys get <id> Get a specific key (JSON)
# keys create --name <n> --scopes <s> Create a new API key
# keys update <id> [flags] Update a key
# keys revoke <id> Revoke a key (prompts confirmation)
# access list <project-id> List keys with access to a project
# access grant <project-id> <key-id> Grant a key access to a project
# access revoke <project-id> <key-id> 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=\"<your-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 <id>" >&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 <name> --scopes <scope1,scope2,...>" >&2
exit 1
fi
if [[ -z "$scopes" ]]; then
echo -e "${RED}Error: --scopes is required${NC}" >&2
echo "Usage: rdev-cli keys create --name <name> --scopes <scope1,scope2,...>" >&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 <id> [--name <n>] [--scopes <s>] [--expires <e>] [--project-ids <ids>] [--allowed-ips <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 <id>" >&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 <project-id>" >&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 <project-id> <key-id>" >&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 <project-id> <key-id>" >&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 <id> Get a specific key (JSON)
rdev-cli keys create --name <n> --scopes <s> Create a new API key
rdev-cli keys update <id> [flags] Update a key
rdev-cli keys revoke <id> Revoke a key (prompts confirmation)
rdev-cli access list <project-id> List keys with access to a project
rdev-cli access grant <project-id> <key-id> Grant a key access to a project
rdev-cli access revoke <project-id> <key-id> Revoke a key's access to a project
Create / Update flags:
--name <name> Key name (required on create)
--scopes <s1,s2,...> Comma-separated scopes (required on create)
--expires <30d|60d|90d|1y|never> Expiration (default: 90d on create)
--project-ids <id1,id2,...> Restrict to projects (null = unrestricted)
--allowed-ips <cidr1,...> 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:-<missing>}${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:-<missing>}${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 "$@"