From 1982911f92299c774d133b95ac029db04cd36404 Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Mon, 16 Feb 2026 13:45:16 +0000 Subject: [PATCH] feat: add cascade dashboard CLI with tRPC client and webhooks router MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a full-featured `cascade` CLI binary for managing the dashboard from the terminal. The CLI consumes the same tRPC endpoints as the web UI — no business logic duplication, full type safety via AppRouter import. - 33 commands across 8 topics: auth, runs, projects, credentials, defaults, org, agents, webhooks - Shared modules: config persistence, tRPC client factory, base command class, output formatting (tables, detail views, relative dates) - New webhooks tRPC router extracted from tools/setup-webhooks.ts - Dual binary support: cascade (dashboard) + cascade-tools (agents) - 67 new tests across 6 test files (config, format, base, client, webhooks router, router integration) - CLAUDE.md updated with CLI documentation and command reference - .claude and .cascade dev environment configs Co-Authored-By: Claude Opus 4.6 --- .cascade/ensure-services.sh | 69 +++ .cascade/env | 3 + .cascade/on-file-edit.sh | 112 +++++ .cascade/on-verify.sh | 96 ++++ .cascade/setup.sh | 269 +++++++++++ .claude/settings.json | 5 + .gitignore | 4 + CLAUDE.md | 101 ++++ bin/cascade-tools.js | 25 +- bin/cascade.js | 3 + package-lock.json | 14 + package.json | 8 +- src/api/router.ts | 2 + src/api/routers/webhooks.ts | 265 +++++++++++ src/cli/dashboard/_shared/base.ts | 73 +++ src/cli/dashboard/_shared/client.ts | 20 + src/cli/dashboard/_shared/config.ts | 48 ++ src/cli/dashboard/_shared/format.ts | 113 +++++ src/cli/dashboard/agents/create.ts | 43 ++ src/cli/dashboard/agents/delete.ts | 36 ++ src/cli/dashboard/agents/list.ts | 37 ++ src/cli/dashboard/agents/update.ts | 43 ++ src/cli/dashboard/credentials/create.ts | 41 ++ src/cli/dashboard/credentials/delete.ts | 36 ++ src/cli/dashboard/credentials/list.ts | 33 ++ src/cli/dashboard/credentials/update.ts | 41 ++ src/cli/dashboard/defaults/set.ts | 46 ++ src/cli/dashboard/defaults/show.ts | 41 ++ src/cli/dashboard/login.ts | 50 ++ src/cli/dashboard/logout.ts | 24 + src/cli/dashboard/org/show.ts | 34 ++ src/cli/dashboard/org/update.ts | 28 ++ src/cli/dashboard/projects/create.ts | 49 ++ src/cli/dashboard/projects/delete.ts | 36 ++ src/cli/dashboard/projects/integration-set.ts | 44 ++ src/cli/dashboard/projects/integrations.ts | 41 ++ src/cli/dashboard/projects/list.ts | 33 ++ src/cli/dashboard/projects/override-rm.ts | 45 ++ src/cli/dashboard/projects/override-set.ts | 51 +++ src/cli/dashboard/projects/overrides.ts | 42 ++ src/cli/dashboard/projects/show.ts | 42 ++ src/cli/dashboard/projects/update.ts | 52 +++ src/cli/dashboard/runs/debug.ts | 36 ++ src/cli/dashboard/runs/list.ts | 55 +++ src/cli/dashboard/runs/llm-call.ts | 36 ++ src/cli/dashboard/runs/llm-calls.ts | 39 ++ src/cli/dashboard/runs/logs.ts | 45 ++ src/cli/dashboard/runs/show.ts | 45 ++ src/cli/dashboard/webhooks/create.ts | 53 +++ src/cli/dashboard/webhooks/delete.ts | 49 ++ src/cli/dashboard/webhooks/list.ts | 51 +++ src/cli/dashboard/whoami.ts | 31 ++ tests/unit/api/router.test.ts | 22 + tests/unit/api/routers/webhooks.test.ts | 432 ++++++++++++++++++ tests/unit/cli/dashboard/base.test.ts | 114 +++++ tests/unit/cli/dashboard/client.test.ts | 59 +++ tests/unit/cli/dashboard/config.test.ts | 171 +++++++ tests/unit/cli/dashboard/format.test.ts | 233 ++++++++++ 58 files changed, 3664 insertions(+), 5 deletions(-) create mode 100755 .cascade/ensure-services.sh create mode 100644 .cascade/env create mode 100755 .cascade/on-file-edit.sh create mode 100755 .cascade/on-verify.sh create mode 100755 .cascade/setup.sh create mode 100644 .claude/settings.json create mode 100644 bin/cascade.js create mode 100644 src/api/routers/webhooks.ts create mode 100644 src/cli/dashboard/_shared/base.ts create mode 100644 src/cli/dashboard/_shared/client.ts create mode 100644 src/cli/dashboard/_shared/config.ts create mode 100644 src/cli/dashboard/_shared/format.ts create mode 100644 src/cli/dashboard/agents/create.ts create mode 100644 src/cli/dashboard/agents/delete.ts create mode 100644 src/cli/dashboard/agents/list.ts create mode 100644 src/cli/dashboard/agents/update.ts create mode 100644 src/cli/dashboard/credentials/create.ts create mode 100644 src/cli/dashboard/credentials/delete.ts create mode 100644 src/cli/dashboard/credentials/list.ts create mode 100644 src/cli/dashboard/credentials/update.ts create mode 100644 src/cli/dashboard/defaults/set.ts create mode 100644 src/cli/dashboard/defaults/show.ts create mode 100644 src/cli/dashboard/login.ts create mode 100644 src/cli/dashboard/logout.ts create mode 100644 src/cli/dashboard/org/show.ts create mode 100644 src/cli/dashboard/org/update.ts create mode 100644 src/cli/dashboard/projects/create.ts create mode 100644 src/cli/dashboard/projects/delete.ts create mode 100644 src/cli/dashboard/projects/integration-set.ts create mode 100644 src/cli/dashboard/projects/integrations.ts create mode 100644 src/cli/dashboard/projects/list.ts create mode 100644 src/cli/dashboard/projects/override-rm.ts create mode 100644 src/cli/dashboard/projects/override-set.ts create mode 100644 src/cli/dashboard/projects/overrides.ts create mode 100644 src/cli/dashboard/projects/show.ts create mode 100644 src/cli/dashboard/projects/update.ts create mode 100644 src/cli/dashboard/runs/debug.ts create mode 100644 src/cli/dashboard/runs/list.ts create mode 100644 src/cli/dashboard/runs/llm-call.ts create mode 100644 src/cli/dashboard/runs/llm-calls.ts create mode 100644 src/cli/dashboard/runs/logs.ts create mode 100644 src/cli/dashboard/runs/show.ts create mode 100644 src/cli/dashboard/webhooks/create.ts create mode 100644 src/cli/dashboard/webhooks/delete.ts create mode 100644 src/cli/dashboard/webhooks/list.ts create mode 100644 src/cli/dashboard/whoami.ts create mode 100644 tests/unit/api/routers/webhooks.test.ts create mode 100644 tests/unit/cli/dashboard/base.test.ts create mode 100644 tests/unit/cli/dashboard/client.test.ts create mode 100644 tests/unit/cli/dashboard/config.test.ts create mode 100644 tests/unit/cli/dashboard/format.test.ts diff --git a/.cascade/ensure-services.sh b/.cascade/ensure-services.sh new file mode 100755 index 00000000..32a2705d --- /dev/null +++ b/.cascade/ensure-services.sh @@ -0,0 +1,69 @@ +#!/bin/bash +# Service health check and restart for CASCADE agent +# Can be run by the agent before running tests or migrations +# +# Usage: .cascade/ensure-services.sh +# Exit codes: +# 0 - All services running +# 1 - Service failed to start + +set -e + +echo "=== Checking Services ===" + +# PostgreSQL check and restart +if pg_isready -q 2>/dev/null; then + echo "PostgreSQL: running" +else + echo "PostgreSQL: down - attempting restart..." + + if [ -d /var/lib/postgresql/data ]; then + PG_CTL=$(find /usr/lib/postgresql -name pg_ctl 2>/dev/null | head -1) + if [ -n "$PG_CTL" ]; then + echo "Found pg_ctl at: $PG_CTL" + + mkdir -p /run/postgresql 2>/dev/null || true + chown postgres:postgres /run/postgresql 2>/dev/null || true + + if su postgres -c "$PG_CTL start -D /var/lib/postgresql/data -l /tmp/postgres.log -w -t 30" 2>/dev/null; then + echo "Started PostgreSQL as postgres user" + elif $PG_CTL start -D /var/lib/postgresql/data -l /tmp/postgres.log -w -t 30 2>/dev/null; then + echo "Started PostgreSQL as current user" + elif command -v pg_ctlcluster &>/dev/null; then + PG_VERSION=$(ls /usr/lib/postgresql/ | sort -V | tail -1) + pg_ctlcluster $PG_VERSION main start 2>/dev/null || true + else + echo "PostgreSQL restart failed - needs manual intervention" + echo "Try: su postgres -c 'pg_ctl start -D /var/lib/postgresql/data'" + fi + fi + elif command -v brew &>/dev/null; then + brew services start postgresql@16 2>/dev/null || \ + brew services start postgresql@15 2>/dev/null || \ + brew services start postgresql 2>/dev/null || true + fi + + # Wait for PostgreSQL to be ready + for i in {1..10}; do + if pg_isready -q 2>/dev/null; then + break + fi + echo "Waiting for PostgreSQL... ($i/10)" + sleep 1 + done + + # Final check + if pg_isready -q 2>/dev/null; then + echo "PostgreSQL: restarted successfully" + else + echo "PostgreSQL: FAILED TO START" + echo "" + echo "Troubleshooting:" + echo " - Check PostgreSQL logs: cat /tmp/postgres.log" + echo " - Check data directory: ls -la /var/lib/postgresql/data" + echo " - Check if another instance is running: ps aux | grep postgres" + exit 1 + fi +fi + +echo "=== All services running ===" diff --git a/.cascade/env b/.cascade/env new file mode 100644 index 00000000..c1be4a9f --- /dev/null +++ b/.cascade/env @@ -0,0 +1,3 @@ +CI=true +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/cascade +DATABASE_SSL=false diff --git a/.cascade/on-file-edit.sh b/.cascade/on-file-edit.sh new file mode 100755 index 00000000..0c4d9f0e --- /dev/null +++ b/.cascade/on-file-edit.sh @@ -0,0 +1,112 @@ +#!/bin/bash +# Post-edit validation for CASCADE agent +# Runs linting and type checking on edited files +# +# Usage: .cascade/on-file-edit.sh +# +# Exit codes: +# 0 - All checks passed (or file type not applicable) +# 1 - Lint errors found +# 2 - Type errors found +# 3 - Both lint and type errors found +# 10 - File not found +# 11 - No file path provided + +set -uo pipefail + +FILE_PATH="${1:-}" + +if [ -z "$FILE_PATH" ]; then + echo "Error: No file path provided" + echo "Usage: $0 " + exit 11 +fi + +# Convert to absolute path if relative +if [[ ! "$FILE_PATH" = /* ]]; then + FILE_PATH="$(pwd)/$FILE_PATH" +fi + +if [ ! -f "$FILE_PATH" ]; then + echo "Error: File not found: $FILE_PATH" + exit 10 +fi + +# Get the project root (where this script lives) +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" + +cd "$PROJECT_ROOT" + +# Make file path relative to project root +REL_PATH="${FILE_PATH#$PROJECT_ROOT/}" + +# Get file extension +EXT="${FILE_PATH##*.}" + +# Determine if file is lintable and/or type-checkable +LINT_APPLICABLE=false +TYPE_APPLICABLE=false + +case "$EXT" in + ts|tsx) + LINT_APPLICABLE=true + TYPE_APPLICABLE=true + ;; + js|jsx|json|jsonc) + LINT_APPLICABLE=true + ;; +esac + +# If neither applies, exit successfully +if [ "$LINT_APPLICABLE" = false ] && [ "$TYPE_APPLICABLE" = false ]; then + exit 0 +fi + +LINT_EXIT=0 +TYPE_EXIT=0 +LINT_OUTPUT="" +TYPE_OUTPUT="" + +# --- Run Biome lint --- +if [ "$LINT_APPLICABLE" = true ]; then + LINT_OUTPUT=$(npx biome check "$REL_PATH" 2>&1) + LINT_EXIT=$? +fi + +# --- Run TypeScript type check --- +if [ "$TYPE_APPLICABLE" = true ]; then + # Determine tsconfig based on file location + if [[ "$REL_PATH" == web/* ]]; then + TYPE_OUTPUT=$(npx tsc --noEmit -p web/tsconfig.json 2>&1) + TYPE_EXIT=$? + else + # src/*, tools/*, tests/*, root files + TYPE_OUTPUT=$(npx tsc --noEmit 2>&1) + TYPE_EXIT=$? + fi +fi + +# --- Output only if there are errors --- +if [ $LINT_EXIT -ne 0 ]; then + echo "=== Biome Check: $REL_PATH ===" + echo "$LINT_OUTPUT" + echo "" +fi + +if [ $TYPE_EXIT -ne 0 ]; then + echo "=== TypeScript Check: $REL_PATH ===" + echo "$TYPE_OUTPUT" + echo "" +fi + +# --- Determine final exit code --- +if [ $LINT_EXIT -ne 0 ] && [ $TYPE_EXIT -ne 0 ]; then + exit 3 +elif [ $LINT_EXIT -ne 0 ]; then + exit 1 +elif [ $TYPE_EXIT -ne 0 ]; then + exit 2 +else + exit 0 +fi diff --git a/.cascade/on-verify.sh b/.cascade/on-verify.sh new file mode 100755 index 00000000..d4010b40 --- /dev/null +++ b/.cascade/on-verify.sh @@ -0,0 +1,96 @@ +#!/bin/bash +# Post-edit verification suite for CASCADE agent +# Runs diagnostics and/or tests based on scope argument +# +# Usage: .cascade/on-verify.sh +# scope: diagnostics | tests | full (default: full) +# +# Exit codes: +# 0 - All checks passed +# 1 - Lint errors found +# 2 - Type errors found +# 3 - Both lint and type errors found +# 4 - Test failures +# 5 - Multiple failure types (diagnostics + tests) + +set -uo pipefail + +SCOPE="${1:-full}" + +# Get the project root (where this script lives) +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" + +cd "$PROJECT_ROOT" + +LINT_EXIT=0 +TYPE_EXIT=0 +TEST_EXIT=0 +LINT_OUTPUT="" +TYPE_OUTPUT="" +TEST_OUTPUT="" + +# --- Diagnostics: Lint + Type Check --- +if [ "$SCOPE" = "diagnostics" ] || [ "$SCOPE" = "full" ]; then + # Lint via Biome + LINT_OUTPUT=$(npm run lint 2>&1) + LINT_EXIT=$? + + # Type check via tsc + TYPE_OUTPUT=$(npm run typecheck 2>&1) + TYPE_EXIT=$? +fi + +# --- Tests: Vitest --- +if [ "$SCOPE" = "tests" ] || [ "$SCOPE" = "full" ]; then + TEST_OUTPUT=$(npm test 2>&1) + TEST_EXIT=$? +fi + +# --- Output results --- +HAS_ERRORS=false + +if [ $LINT_EXIT -ne 0 ]; then + HAS_ERRORS=true + echo "=== Biome Lint ===" + echo "$LINT_OUTPUT" + echo "" +fi + +if [ $TYPE_EXIT -ne 0 ]; then + HAS_ERRORS=true + echo "=== TypeScript ===" + echo "$TYPE_OUTPUT" + echo "" +fi + +if [ $TEST_EXIT -ne 0 ]; then + HAS_ERRORS=true + echo "=== Tests ===" + echo "$TEST_OUTPUT" + echo "" +fi + +if [ "$HAS_ERRORS" = false ]; then + echo "All checks passed." +fi + +# --- Determine final exit code --- +DIAG_FAILED=false +if [ $LINT_EXIT -ne 0 ] || [ $TYPE_EXIT -ne 0 ]; then + DIAG_FAILED=true +fi + +if [ "$DIAG_FAILED" = true ] && [ $TEST_EXIT -ne 0 ]; then + exit 5 +elif [ $LINT_EXIT -ne 0 ] && [ $TYPE_EXIT -ne 0 ]; then + exit 3 +elif [ $LINT_EXIT -ne 0 ]; then + exit 1 +elif [ $TYPE_EXIT -ne 0 ]; then + exit 2 +elif [ $TEST_EXIT -ne 0 ]; then + exit 4 +else + exit 0 +fi diff --git a/.cascade/setup.sh b/.cascade/setup.sh new file mode 100755 index 00000000..12a6e254 --- /dev/null +++ b/.cascade/setup.sh @@ -0,0 +1,269 @@ +#!/bin/bash +set -e + +echo "=== CASCADE Project Setup ===" +echo "Agent profile: ${AGENT_PROFILE_NAME:-not set}" + +# ============================================================================= +# Helper functions +# ============================================================================= +log_info() { + echo "[INFO] $1" +} + +log_warn() { + echo "[WARN] $1" +} + +log_error() { + echo "[ERROR] $1" +} + +# Detect OS +detect_os() { + case "$(uname -s)" in + Darwin*) echo "macos" ;; + Linux*) echo "linux" ;; + *) echo "unknown" ;; + esac +} + +OS=$(detect_os) +log_info "Detected OS: $OS" + +# Get the project root directory (parent of .cascade) +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +log_info "Project root: $PROJECT_ROOT" + +# Change to project root for all subsequent commands +cd "$PROJECT_ROOT" + +# ============================================================================= +# 0. Prerequisites Check +# ============================================================================= +echo "" +echo "--- Checking Prerequisites ---" + +# Check for Node.js +if ! command -v node &> /dev/null; then + log_error "Node.js is not installed" + exit 1 +fi +log_info "Node.js: $(node --version)" + +# Check for npm +if ! command -v npm &> /dev/null; then + log_error "npm is not installed" + exit 1 +fi +log_info "npm: $(npm --version)" + +# ============================================================================= +# 1. Install Dependencies (only for coding agents) +# ============================================================================= +case "$AGENT_PROFILE_NAME" in + implementation|respond-to-review|review|respond-to-ci) + echo "" + echo "--- Installing Dependencies ---" + + # Root dependencies + log_info "Installing root dependencies..." + CI=true PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm install + log_info "Root dependencies installed" + + # Frontend dependencies + log_info "Installing frontend dependencies..." + cd web && CI=true npm install && cd .. + log_info "Frontend dependencies installed" + ;; + *) + echo "" + log_info "Skipping dependency installation (agent: ${AGENT_PROFILE_NAME:-unknown})" + ;; +esac + +# ============================================================================= +# 2. PostgreSQL Setup +# ============================================================================= +echo "" +echo "--- PostgreSQL Setup ---" + +start_postgres_macos() { + if command -v brew &> /dev/null; then + # Check which postgresql is installed + local pg_service="" + for ver in 17 16 15 14 13; do + if brew list "postgresql@$ver" &> /dev/null; then + pg_service="postgresql@$ver" + break + fi + done + + if [ -z "$pg_service" ] && brew list postgresql &> /dev/null; then + pg_service="postgresql" + fi + + if [ -z "$pg_service" ]; then + log_info "PostgreSQL not installed, installing postgresql@16..." + brew install postgresql@16 + pg_service="postgresql@16" + fi + + log_info "Using $pg_service" + + # Start the service + if ! pg_isready -q 2>/dev/null; then + log_info "Starting PostgreSQL..." + brew services start "$pg_service" 2>/dev/null || true + + for i in {1..15}; do + if pg_isready -q 2>/dev/null; then + break + fi + log_info "Waiting for PostgreSQL... ($i/15)" + sleep 1 + done + fi + else + log_error "Homebrew not found on macOS. Please install PostgreSQL manually." + return 1 + fi +} + +start_postgres_linux() { + # Check if PostgreSQL SERVER is installed + local pg_ctl_path + pg_ctl_path=$(find /usr/lib/postgresql -name pg_ctl 2>/dev/null | head -1 || true) + + if [ -z "$pg_ctl_path" ]; then + log_info "PostgreSQL server not found, installing..." + if command -v apt-get &> /dev/null; then + sudo apt-get update && sudo apt-get install -y postgresql postgresql-client + local pg_version + pg_version=$(ls /usr/lib/postgresql/ | sort -V | tail -1) + log_info "Installed PostgreSQL version: $pg_version" + + if [ ! -d /var/lib/postgresql/data ] || [ -z "$(ls -A /var/lib/postgresql/data 2>/dev/null)" ]; then + sudo mkdir -p /var/lib/postgresql/data + sudo chown postgres:postgres /var/lib/postgresql/data + sudo su postgres -c "/usr/lib/postgresql/$pg_version/bin/initdb -D /var/lib/postgresql/data" + log_info "PostgreSQL data directory initialized" + fi + else + log_error "Cannot install PostgreSQL - apt-get not available" + return 1 + fi + fi + + # Start PostgreSQL if not running + if ! pg_isready -q 2>/dev/null; then + log_info "Starting PostgreSQL..." + + local pg_data="/var/lib/postgresql/data" + local pg_log="/tmp/postgres.log" + + if [ -d "$pg_data" ] && [ -n "$(ls -A "$pg_data" 2>/dev/null)" ]; then + local pg_ctl + pg_ctl=$(find /usr/lib/postgresql -name pg_ctl 2>/dev/null | head -1 || echo "pg_ctl") + + # Ensure runtime directory exists + sudo mkdir -p /run/postgresql 2>/dev/null || true + sudo chown postgres:postgres /run/postgresql 2>/dev/null || true + + if ! sudo su postgres -c "$pg_ctl status -D $pg_data" 2>&1 | grep -q "server is running"; then + sudo su postgres -c "$pg_ctl start -D $pg_data -l $pg_log -w" 2>&1 || { + log_error "pg_ctl start failed" + cat "$pg_log" 2>/dev/null || true + } + fi + elif command -v pg_ctlcluster &> /dev/null; then + local cluster_info + cluster_info=$(pg_lsclusters -h 2>/dev/null | head -1) + if [ -n "$cluster_info" ]; then + sudo pg_ctlcluster $(echo "$cluster_info" | awk '{print $1, $2}') start 2>/dev/null || true + fi + fi + + # Wait for PostgreSQL to be ready + for i in {1..15}; do + if pg_isready -q 2>/dev/null; then + break + fi + log_info "Waiting for PostgreSQL... ($i/15)" + sleep 1 + done + fi +} + +# Start PostgreSQL based on OS +case "$OS" in + macos) start_postgres_macos ;; + linux) start_postgres_linux ;; + *) log_warn "Unknown OS, skipping PostgreSQL auto-start" ;; +esac + +# Verify PostgreSQL is running +if pg_isready -q 2>/dev/null; then + log_info "PostgreSQL is running" +else + log_error "PostgreSQL failed to start" + # Don't exit - let subsequent steps handle the failure gracefully +fi + +# ============================================================================= +# 3. Create PostgreSQL database +# ============================================================================= +if pg_isready -q 2>/dev/null; then + echo "" + echo "--- Setting up PostgreSQL database ---" + + # Determine psql command based on OS + PSQL_CMD="psql" + if [ "$OS" = "linux" ]; then + PSQL_CMD="sudo -u postgres psql" + fi + + # Create cascade database + if ! $PSQL_CMD -lqt 2>/dev/null | cut -d \| -f 1 | grep -qw cascade; then + log_info "Creating cascade database..." + if [ "$OS" = "linux" ]; then + $PSQL_CMD -c "CREATE DATABASE cascade;" 2>/dev/null || true + else + createdb cascade 2>/dev/null || true + fi + else + log_info "Database cascade already exists" + fi + + # On Linux, ensure postgres user has a known password for app connections + if [ "$OS" = "linux" ]; then + $PSQL_CMD -c "ALTER USER postgres WITH PASSWORD 'postgres';" 2>/dev/null || true + fi + + log_info "PostgreSQL database setup complete" +fi + +# ============================================================================= +# 4. Run migrations +# ============================================================================= +echo "" +echo "--- Database Migrations ---" + +if pg_isready -q 2>/dev/null; then + log_info "Running migrations..." + DATABASE_SSL=false npm run db:migrate 2>&1 || \ + log_warn "Migration failed - may need manual intervention" +else + log_warn "PostgreSQL not ready, skipping migrations" +fi + +# ============================================================================= +# Summary +# ============================================================================= +echo "" +echo "=== CASCADE Setup Complete ===" +echo "OS: $OS" +echo "PostgreSQL: $(pg_isready 2>&1 || echo 'not running')" +echo "Node: $(node --version)" +echo "npm: $(npm --version)" diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..90308884 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,5 @@ +{ + "enabledPlugins": { + "frontend-design@claude-plugins-official": true + } +} diff --git a/.gitignore b/.gitignore index 40a24a10..07b0b866 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,7 @@ workspace/ # Test artifacts test-results/ + +# Claude Code — commit settings.json but exclude local settings (may contain secrets) +.claude/settings.local.json + diff --git a/CLAUDE.md b/CLAUDE.md index da034eb6..3081391c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,6 +58,7 @@ Lefthook runs pre-commit (lint, typecheck) and pre-push (test) hooks automatical - `src/triggers/` - Extensible trigger system (Trello, GitHub) - `src/agents/` - AI agent implementations - `src/gadgets/` - Custom gadgets (Trello, Git) +- `src/cli/dashboard/` - Dashboard CLI commands (`cascade` binary) - `src/api/` - Dashboard API (tRPC routers, auth handlers) - `src/trello/` - Trello API client - `src/utils/` - Utilities (logging, repo cloning, lifecycle) @@ -241,6 +242,106 @@ psql $DATABASE_URL -c "INSERT INTO users (org_id, email, password_hash, name, ro - `src/api/auth/` - Login/logout Hono handlers, session resolution - `web/src/lib/trpc.ts` - Frontend tRPC client (type-safe via AppRouter import) +## CLI (`cascade`) + +CASCADE includes a `cascade` CLI for managing the platform from the terminal. It consumes the same tRPC endpoints as the web dashboard — no business logic duplication, full type safety. + +### Setup + +```bash +npm run build # Compile TypeScript +cascade login --server http://localhost:3000 --email you@example.com --password secret +cascade whoami # Verify session +``` + +Config is stored in `~/.cascade/cli.json`. Override with env vars for CI/scripts: + +```bash +export CASCADE_SERVER_URL=http://localhost:3000 +export CASCADE_SESSION_TOKEN= +``` + +### Commands + +```bash +# Auth +cascade login --server URL --email X --password Y +cascade logout +cascade whoami + +# Runs +cascade runs list [--project ID] [--status running,failed] [--agent-type impl] [--limit 20] +cascade runs show +cascade runs logs # Pipe: cascade runs logs ID | grep error +cascade runs llm-calls +cascade runs llm-call +cascade runs debug + +# Projects +cascade projects list +cascade projects show +cascade projects create --id my-project --name "My Project" --repo owner/repo +cascade projects update --model claude-sonnet-4-5-20250929 +cascade projects delete --yes +cascade projects integrations +cascade projects integration-set --type trello --config '{"boardId":"..."}' +cascade projects overrides +cascade projects override-set --key GITHUB_TOKEN --credential-id 5 +cascade projects override-set --key GITHUB_TOKEN --credential-id 7 --agent-type review +cascade projects override-rm --key GITHUB_TOKEN + +# Credentials +cascade credentials list +cascade credentials create --name "GitHub Bot" --key GITHUB_TOKEN --value ghp_... [--default] +cascade credentials update --value new-secret +cascade credentials delete --yes + +# Defaults +cascade defaults show +cascade defaults set --model claude-sonnet-4-5-20250929 --max-iterations 25 --agent-backend claude-code + +# Organization +cascade org show +cascade org update --name "My Org" + +# Agent Configs +cascade agents list [--project-id ID] +cascade agents create --agent-type implementation --model claude-sonnet-4-5-20250929 [--project-id ID] +cascade agents update --max-iterations 30 +cascade agents delete --yes + +# Webhooks +cascade webhooks list +cascade webhooks create --callback-url https://cascade.example.com +cascade webhooks delete --callback-url https://cascade.example.com +``` + +### Global Flags + +- `--json` — Machine-readable JSON output (all commands). Pipe to `jq` for scripting. +- `--server URL` — Override server URL for a single invocation. + +### Architecture + +Each command is a thin adapter: **parse flags → call tRPC → format output**. All business logic lives server-side. + +``` +src/cli/dashboard/ +├── _shared/ # Config, tRPC client, base class, formatters +├── login.ts # Auth (HTTP, not tRPC) +├── logout.ts +├── whoami.ts +├── runs/ # 6 commands +├── projects/ # 10 commands +├── credentials/ # 4 commands +├── defaults/ # 2 commands +├── org/ # 2 commands +├── agents/ # 4 commands +└── webhooks/ # 3 commands +``` + +The `cascade` binary is separate from `cascade-tools` (which is for agents). The `cascade-tools` binary uses a custom oclif config in `bin/cascade-tools.js` to discover only agent tool commands (`dist/cli/trello/`, `dist/cli/github/`, `dist/cli/session/`), while `cascade` discovers only dashboard commands (`dist/cli/dashboard/`). + ## Adding New Triggers 1. Create trigger handler in `src/triggers/` diff --git a/bin/cascade-tools.js b/bin/cascade-tools.js index c194df91..b0c351d3 100755 --- a/bin/cascade-tools.js +++ b/bin/cascade-tools.js @@ -1,3 +1,24 @@ #!/usr/bin/env node -import { execute } from '@oclif/core'; -await execute({ dir: import.meta.url }); +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Config, run } from '@oclif/core'; + +// cascade-tools uses its own oclif config independent of package.json, +// which now points to the dashboard CLI (cascade binary). +const __dirname = dirname(fileURLToPath(import.meta.url)); +const root = resolve(__dirname, '..'); +const pjson = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf-8')); + +pjson.oclif = { + bin: 'cascade-tools', + commands: { + strategy: 'pattern', + target: './dist/cli', + globPatterns: ['**/*.js', '!**/dashboard/**', '!**/_shared/**', '!base.js'], + }, + topicSeparator: ' ', +}; + +const config = await Config.load({ root, pjson }); +await run(process.argv.slice(2), config); diff --git a/bin/cascade.js b/bin/cascade.js new file mode 100644 index 00000000..c194df91 --- /dev/null +++ b/bin/cascade.js @@ -0,0 +1,3 @@ +#!/usr/bin/env node +import { execute } from '@oclif/core'; +await execute({ dir: import.meta.url }); diff --git a/package-lock.json b/package-lock.json index 948cf0c8..df7b8d8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@llmist/cli": "^15.2.1", "@oclif/core": "^4.8.0", "@octokit/rest": "^22.0.1", + "@trpc/client": "^11.10.0", "@trpc/server": "^11.10.0", "@types/archiver": "^7.0.0", "archiver": "^7.0.1", @@ -2815,6 +2816,19 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@trpc/client": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/@trpc/client/-/client-11.10.0.tgz", + "integrity": "sha512-h0s2AwDtuhS8INRb4hlo4z3RKCkarWqlOy+3ffJgrlDxzzW6aLUN+9nDrcN4huPje1Em15tbCOqhIc6oaKYTRw==", + "funding": [ + "https://trpc.io/sponsor" + ], + "license": "MIT", + "peerDependencies": { + "@trpc/server": "11.10.0", + "typescript": ">=5.7.2" + } + }, "node_modules/@trpc/server": { "version": "11.10.0", "resolved": "https://registry.npmjs.org/@trpc/server/-/server-11.10.0.tgz", diff --git a/package.json b/package.json index 49ac6138..59f45d32 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@llmist/cli": "^15.2.1", "@oclif/core": "^4.8.0", "@octokit/rest": "^22.0.1", + "@trpc/client": "^11.10.0", "@trpc/server": "^11.10.0", "@types/archiver": "^7.0.0", "archiver": "^7.0.1", @@ -80,13 +81,14 @@ "vitest": "^2.1.8" }, "bin": { - "cascade-tools": "./bin/cascade-tools.js" + "cascade-tools": "./bin/cascade-tools.js", + "cascade": "./bin/cascade.js" }, "oclif": { - "bin": "cascade-tools", + "bin": "cascade", "commands": { "strategy": "pattern", - "target": "./dist/cli", + "target": "./dist/cli/dashboard", "globPatterns": [ "**/*.js", "!**/_shared/**" diff --git a/src/api/router.ts b/src/api/router.ts index 5f84d64c..0bb7e9d4 100644 --- a/src/api/router.ts +++ b/src/api/router.ts @@ -5,6 +5,7 @@ import { defaultsRouter } from './routers/defaults.js'; import { organizationRouter } from './routers/organization.js'; import { projectsRouter } from './routers/projects.js'; import { runsRouter } from './routers/runs.js'; +import { webhooksRouter } from './routers/webhooks.js'; import { router } from './trpc.js'; export const appRouter = router({ @@ -15,6 +16,7 @@ export const appRouter = router({ defaults: defaultsRouter, credentials: credentialsRouter, agentConfigs: agentConfigsRouter, + webhooks: webhooksRouter, }); export type AppRouter = typeof appRouter; diff --git a/src/api/routers/webhooks.ts b/src/api/routers/webhooks.ts new file mode 100644 index 00000000..7fbb1a67 --- /dev/null +++ b/src/api/routers/webhooks.ts @@ -0,0 +1,265 @@ +import { Octokit } from '@octokit/rest'; +import { TRPCError } from '@trpc/server'; +import { eq } from 'drizzle-orm'; +import { z } from 'zod'; +import { getDb } from '../../db/client.js'; +import { findProjectByIdFromDb } from '../../db/repositories/configRepository.js'; +import { resolveAllCredentials } from '../../db/repositories/credentialsRepository.js'; +import { projects } from '../../db/schema/index.js'; +import { protectedProcedure, router } from '../trpc.js'; + +const GITHUB_WEBHOOK_EVENTS = [ + 'pull_request', + 'pull_request_review', + 'check_suite', + 'issue_comment', +]; + +export interface TrelloWebhook { + id: string; + description: string; + idModel: string; + callbackURL: string; + active: boolean; +} + +export interface GitHubWebhook { + id: number; + name: string; + active: boolean; + events: string[]; + config: { url?: string; content_type?: string }; +} + +interface ProjectContext { + projectId: string; + orgId: string; + repo: string; + boardId: string; + trelloApiKey: string; + trelloToken: string; + githubToken: string; +} + +async function resolveProjectContext( + projectId: string, + userOrgId: string, +): Promise { + // Verify ownership + const db = getDb(); + const [proj] = await db + .select({ orgId: projects.orgId }) + .from(projects) + .where(eq(projects.id, projectId)); + if (!proj || proj.orgId !== userOrgId) { + throw new TRPCError({ code: 'NOT_FOUND' }); + } + + const project = await findProjectByIdFromDb(projectId); + if (!project) { + throw new TRPCError({ code: 'NOT_FOUND' }); + } + + const creds = await resolveAllCredentials(projectId, project.orgId); + + return { + projectId, + orgId: project.orgId, + repo: project.repo, + boardId: project.trello.boardId, + trelloApiKey: creds.TRELLO_API_KEY ?? '', + trelloToken: creds.TRELLO_TOKEN ?? '', + githubToken: creds.GITHUB_TOKEN ?? '', + }; +} + +// --- Trello helpers --- + +async function trelloListWebhooks(ctx: ProjectContext): Promise { + if (!ctx.trelloApiKey || !ctx.trelloToken) return []; + const response = await fetch( + `https://api.trello.com/1/tokens/${ctx.trelloToken}/webhooks?key=${ctx.trelloApiKey}`, + ); + if (!response.ok) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: `Failed to list Trello webhooks: ${response.status}`, + }); + } + const webhooks = (await response.json()) as TrelloWebhook[]; + return webhooks.filter((w) => w.idModel === ctx.boardId); +} + +async function trelloCreateWebhook( + ctx: ProjectContext, + callbackURL: string, +): Promise { + const response = await fetch( + `https://api.trello.com/1/webhooks/?key=${ctx.trelloApiKey}&token=${ctx.trelloToken}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + callbackURL, + idModel: ctx.boardId, + description: `CASCADE webhook for project ${ctx.projectId}`, + }), + }, + ); + if (!response.ok) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: `Failed to create Trello webhook: ${response.status}`, + }); + } + return (await response.json()) as TrelloWebhook; +} + +async function trelloDeleteWebhook(ctx: ProjectContext, webhookId: string): Promise { + const response = await fetch( + `https://api.trello.com/1/webhooks/${webhookId}?key=${ctx.trelloApiKey}&token=${ctx.trelloToken}`, + { method: 'DELETE' }, + ); + if (!response.ok) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: `Failed to delete Trello webhook ${webhookId}: ${response.status}`, + }); + } +} + +// --- GitHub helpers --- + +function parseRepo(repo: string): { owner: string; repo: string } { + const [owner, name] = repo.split('/'); + return { owner, repo: name }; +} + +async function githubListWebhooks(ctx: ProjectContext): Promise { + if (!ctx.githubToken) return []; + const octokit = new Octokit({ auth: ctx.githubToken }); + const { owner, repo } = parseRepo(ctx.repo); + const { data } = await octokit.repos.listWebhooks({ owner, repo }); + return data as GitHubWebhook[]; +} + +async function githubCreateWebhook( + ctx: ProjectContext, + callbackURL: string, +): Promise { + const octokit = new Octokit({ auth: ctx.githubToken }); + const { owner, repo } = parseRepo(ctx.repo); + const { data } = await octokit.repos.createWebhook({ + owner, + repo, + config: { url: callbackURL, content_type: 'json' }, + events: GITHUB_WEBHOOK_EVENTS, + active: true, + }); + return data as GitHubWebhook; +} + +async function githubDeleteWebhook(ctx: ProjectContext, hookId: number): Promise { + const octokit = new Octokit({ auth: ctx.githubToken }); + const { owner, repo } = parseRepo(ctx.repo); + await octokit.repos.deleteWebhook({ owner, repo, hook_id: hookId }); +} + +// --- Router --- + +export const webhooksRouter = router({ + list: protectedProcedure + .input(z.object({ projectId: z.string() })) + .query(async ({ ctx, input }) => { + const pctx = await resolveProjectContext(input.projectId, ctx.user.orgId); + + const [trello, github] = await Promise.all([ + trelloListWebhooks(pctx), + githubListWebhooks(pctx), + ]); + + return { trello, github }; + }), + + create: protectedProcedure + .input( + z.object({ + projectId: z.string(), + callbackBaseUrl: z.string().url(), + trelloOnly: z.boolean().optional(), + githubOnly: z.boolean().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const pctx = await resolveProjectContext(input.projectId, ctx.user.orgId); + const baseUrl = input.callbackBaseUrl.replace(/\/$/, ''); + const results: { trello?: TrelloWebhook | string; github?: GitHubWebhook | string } = {}; + + // Trello webhook + if (!input.githubOnly && pctx.trelloApiKey && pctx.trelloToken) { + const trelloCallbackUrl = `${baseUrl}/webhook/trello`; + const existing = await trelloListWebhooks(pctx); + const duplicate = existing.find((w) => w.callbackURL === trelloCallbackUrl); + + if (duplicate) { + results.trello = `Already exists: ${duplicate.id}`; + } else { + results.trello = await trelloCreateWebhook(pctx, trelloCallbackUrl); + } + } + + // GitHub webhook + if (!input.trelloOnly && pctx.githubToken) { + const githubCallbackUrl = `${baseUrl}/webhook/github`; + const existing = await githubListWebhooks(pctx); + const duplicate = existing.find((w) => w.config.url === githubCallbackUrl); + + if (duplicate) { + results.github = `Already exists: ${duplicate.id}`; + } else { + results.github = await githubCreateWebhook(pctx, githubCallbackUrl); + } + } + + return results; + }), + + delete: protectedProcedure + .input( + z.object({ + projectId: z.string(), + callbackBaseUrl: z.string().url(), + trelloOnly: z.boolean().optional(), + githubOnly: z.boolean().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const pctx = await resolveProjectContext(input.projectId, ctx.user.orgId); + const baseUrl = input.callbackBaseUrl.replace(/\/$/, ''); + const deleted: { trello: string[]; github: number[] } = { trello: [], github: [] }; + + // Trello + if (!input.githubOnly && pctx.trelloApiKey && pctx.trelloToken) { + const trelloCallbackUrl = `${baseUrl}/webhook/trello`; + const existing = await trelloListWebhooks(pctx); + const matching = existing.filter((w) => w.callbackURL === trelloCallbackUrl); + for (const w of matching) { + await trelloDeleteWebhook(pctx, w.id); + deleted.trello.push(w.id); + } + } + + // GitHub + if (!input.trelloOnly && pctx.githubToken) { + const githubCallbackUrl = `${baseUrl}/webhook/github`; + const existing = await githubListWebhooks(pctx); + const matching = existing.filter((w) => w.config.url === githubCallbackUrl); + for (const w of matching) { + await githubDeleteWebhook(pctx, w.id); + deleted.github.push(w.id); + } + } + + return deleted; + }), +}); diff --git a/src/cli/dashboard/_shared/base.ts b/src/cli/dashboard/_shared/base.ts new file mode 100644 index 00000000..bbfa2996 --- /dev/null +++ b/src/cli/dashboard/_shared/base.ts @@ -0,0 +1,73 @@ +import { Command, Flags } from '@oclif/core'; +import { TRPCClientError } from '@trpc/client'; +import { type DashboardClient, createDashboardClient } from './client.js'; +import { type CliConfig, loadConfig } from './config.js'; +import { printDetail, printTable } from './format.js'; + +export abstract class DashboardCommand extends Command { + static override baseFlags = { + json: Flags.boolean({ description: 'Output as JSON', default: false }), + server: Flags.string({ description: 'Override server URL' }), + }; + + private _client: DashboardClient | undefined; + private _config: CliConfig | undefined; + + protected get config_(): CliConfig { + if (!this._config) { + const config = loadConfig(); + if (!config) { + this.error('Not logged in. Run `cascade login` first.'); + } + this._config = config; + } + return this._config; + } + + protected get client(): DashboardClient { + if (!this._client) { + const config = this.config_; + // Allow --server flag to override + const flags = this.parseBaseFlags(); + if (flags?.server) { + config.serverUrl = flags.server; + } + this._client = createDashboardClient(config); + } + return this._client; + } + + private parseBaseFlags(): { server?: string; json?: boolean } | undefined { + // Base flags are parsed in run() — this is a fallback for the getter + return undefined; + } + + protected outputJson(data: unknown): void { + console.log(JSON.stringify(data, null, 2)); + } + + protected outputTable( + rows: Record[], + columns: { key: string; header: string; format?: (v: unknown) => string }[], + ): void { + printTable(rows, columns); + } + + protected outputDetail( + obj: Record, + fields: Record string }>, + ): void { + printDetail(obj, fields); + } + + protected handleError(err: unknown): never { + if (err instanceof TRPCClientError) { + const code = (err.data as { code?: string } | undefined)?.code; + if (code === 'UNAUTHORIZED') { + this.error('Session expired. Run `cascade login`.'); + } + this.error(err.message); + } + throw err; + } +} diff --git a/src/cli/dashboard/_shared/client.ts b/src/cli/dashboard/_shared/client.ts new file mode 100644 index 00000000..376010ab --- /dev/null +++ b/src/cli/dashboard/_shared/client.ts @@ -0,0 +1,20 @@ +import { createTRPCClient, httpBatchLink } from '@trpc/client'; +import type { AppRouter } from '../../../api/router.js'; +import type { CliConfig } from './config.js'; + +export type DashboardClient = ReturnType; + +export function createDashboardClient(config: CliConfig) { + return createTRPCClient({ + links: [ + httpBatchLink({ + url: `${config.serverUrl}/trpc`, + headers() { + return { + Cookie: `cascade_session=${config.sessionToken}`, + }; + }, + }), + ], + }); +} diff --git a/src/cli/dashboard/_shared/config.ts b/src/cli/dashboard/_shared/config.ts new file mode 100644 index 00000000..3936747a --- /dev/null +++ b/src/cli/dashboard/_shared/config.ts @@ -0,0 +1,48 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +export interface CliConfig { + serverUrl: string; + sessionToken: string; +} + +const CONFIG_DIR = join(homedir(), '.cascade'); +const CONFIG_FILE = join(CONFIG_DIR, 'cli.json'); + +export function loadConfig(): CliConfig | null { + // Env var overrides take priority + const envUrl = process.env.CASCADE_SERVER_URL; + const envToken = process.env.CASCADE_SESSION_TOKEN; + if (envUrl && envToken) { + return { serverUrl: envUrl, sessionToken: envToken }; + } + + if (!existsSync(CONFIG_FILE)) return null; + + try { + const raw = readFileSync(CONFIG_FILE, 'utf-8'); + const parsed = JSON.parse(raw) as Partial; + if (!parsed.serverUrl || !parsed.sessionToken) return null; + + return { + serverUrl: envUrl ?? parsed.serverUrl, + sessionToken: envToken ?? parsed.sessionToken, + }; + } catch { + return null; + } +} + +export function saveConfig(config: CliConfig): void { + if (!existsSync(CONFIG_DIR)) { + mkdirSync(CONFIG_DIR, { recursive: true }); + } + writeFileSync(CONFIG_FILE, `${JSON.stringify(config, null, 2)}\n`, 'utf-8'); +} + +export function clearConfig(): void { + if (existsSync(CONFIG_FILE)) { + writeFileSync(CONFIG_FILE, '{}', 'utf-8'); + } +} diff --git a/src/cli/dashboard/_shared/format.ts b/src/cli/dashboard/_shared/format.ts new file mode 100644 index 00000000..df2a23e8 --- /dev/null +++ b/src/cli/dashboard/_shared/format.ts @@ -0,0 +1,113 @@ +import chalk from 'chalk'; + +// biome-ignore lint/suspicious/noControlCharactersInRegex: stripping ANSI escape codes requires matching ESC +const ANSI_STRIP_RE = /\u001B\[\d+m/g; + +interface Column { + key: string; + header: string; + width?: number; + format?: (value: unknown) => string; +} + +export function printTable(rows: Record[], columns: Column[]): void { + if (rows.length === 0) { + console.log(' (no results)'); + return; + } + + // Calculate column widths + const widths = columns.map((col) => { + const headerLen = col.header.length; + const maxDataLen = rows.reduce((max, row) => { + const formatted = col.format ? col.format(row[col.key]) : String(row[col.key] ?? ''); + // Strip ANSI codes for width calculation + const plain = formatted.replace(ANSI_STRIP_RE, ''); + return Math.max(max, plain.length); + }, 0); + return col.width ?? Math.max(headerLen, Math.min(maxDataLen, 60)); + }); + + // Print header + const header = columns.map((col, i) => col.header.padEnd(widths[i])).join(' '); + console.log(chalk.bold(header)); + console.log(columns.map((_, i) => '─'.repeat(widths[i])).join(' ')); + + // Print rows + for (const row of rows) { + const line = columns + .map((col, i) => { + const formatted = col.format ? col.format(row[col.key]) : String(row[col.key] ?? ''); + const plain = formatted.replace(ANSI_STRIP_RE, ''); + const padding = Math.max(0, widths[i] - plain.length); + return formatted + ' '.repeat(padding); + }) + .join(' '); + console.log(line); + } +} + +interface FieldMap { + label: string; + format?: (value: unknown) => string; +} + +export function printDetail(obj: Record, fields: Record): void { + const maxLabel = Math.max(...Object.values(fields).map((f) => f.label.length)); + + for (const [key, field] of Object.entries(fields)) { + const value = obj[key]; + const formatted = field.format ? field.format(value) : String(value ?? '—'); + console.log(` ${chalk.bold(field.label.padEnd(maxLabel))} ${formatted}`); + } +} + +export function formatDate(iso: unknown): string { + if (!iso) return '—'; + const date = new Date(String(iso)); + const now = Date.now(); + const diffMs = now - date.getTime(); + const diffSec = Math.floor(diffMs / 1000); + + if (diffSec < 60) return 'just now'; + if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`; + if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`; + if (diffSec < 604800) return `${Math.floor(diffSec / 86400)}d ago`; + return date.toISOString().slice(0, 10); +} + +export function formatDuration(ms: unknown): string { + if (ms == null) return '—'; + const totalMs = Number(ms); + if (totalMs < 1000) return `${totalMs}ms`; + const sec = Math.floor(totalMs / 1000); + if (sec < 60) return `${sec}s`; + const min = Math.floor(sec / 60); + const remSec = sec % 60; + return `${min}m ${remSec}s`; +} + +export function formatCost(usd: unknown): string { + if (usd == null) return '—'; + return `$${Number(usd).toFixed(2)}`; +} + +export function formatStatus(status: unknown): string { + const s = String(status ?? ''); + switch (s) { + case 'running': + return chalk.blue(s); + case 'success': + return chalk.green(s); + case 'failed': + return chalk.red(s); + case 'cancelled': + return chalk.yellow(s); + default: + return s; + } +} + +export function formatBoolean(val: unknown): string { + return val ? chalk.green('yes') : chalk.dim('no'); +} diff --git a/src/cli/dashboard/agents/create.ts b/src/cli/dashboard/agents/create.ts new file mode 100644 index 00000000..195651d7 --- /dev/null +++ b/src/cli/dashboard/agents/create.ts @@ -0,0 +1,43 @@ +import { Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +export default class AgentsCreate extends DashboardCommand { + static override description = 'Create an agent configuration.'; + + static override flags = { + ...DashboardCommand.baseFlags, + 'agent-type': Flags.string({ + description: 'Agent type (e.g. implementation, review)', + required: true, + }), + 'project-id': Flags.string({ description: 'Scope to specific project' }), + model: Flags.string({ description: 'Model override' }), + 'max-iterations': Flags.integer({ description: 'Max iterations override' }), + backend: Flags.string({ description: 'Agent backend override' }), + prompt: Flags.string({ description: 'Custom prompt override' }), + }; + + async run(): Promise { + const { flags } = await this.parse(AgentsCreate); + + try { + const result = await this.client.agentConfigs.create.mutate({ + agentType: flags['agent-type'], + projectId: flags['project-id'], + model: flags.model, + maxIterations: flags['max-iterations'], + agentBackend: flags.backend, + prompt: flags.prompt, + }); + + if (flags.json) { + this.outputJson(result); + return; + } + + this.log(`Created agent config for ${flags['agent-type']}`); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/agents/delete.ts b/src/cli/dashboard/agents/delete.ts new file mode 100644 index 00000000..1ddaeab9 --- /dev/null +++ b/src/cli/dashboard/agents/delete.ts @@ -0,0 +1,36 @@ +import { Args, Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +export default class AgentsDelete extends DashboardCommand { + static override description = 'Delete an agent configuration.'; + + static override args = { + id: Args.integer({ description: 'Agent config ID', required: true }), + }; + + static override flags = { + ...DashboardCommand.baseFlags, + yes: Flags.boolean({ description: 'Skip confirmation', char: 'y', default: false }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(AgentsDelete); + + if (!flags.yes) { + this.error('Pass --yes to confirm deletion.'); + } + + try { + await this.client.agentConfigs.delete.mutate({ id: args.id }); + + if (flags.json) { + this.outputJson({ ok: true }); + return; + } + + this.log(`Deleted agent config #${args.id}`); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/agents/list.ts b/src/cli/dashboard/agents/list.ts new file mode 100644 index 00000000..06999ff7 --- /dev/null +++ b/src/cli/dashboard/agents/list.ts @@ -0,0 +1,37 @@ +import { Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +export default class AgentsList extends DashboardCommand { + static override description = 'List agent configurations.'; + + static override flags = { + ...DashboardCommand.baseFlags, + 'project-id': Flags.string({ description: 'Filter by project ID' }), + }; + + async run(): Promise { + const { flags } = await this.parse(AgentsList); + + try { + const configs = await this.client.agentConfigs.list.query( + flags['project-id'] ? { projectId: flags['project-id'] } : undefined, + ); + + if (flags.json) { + this.outputJson(configs); + return; + } + + this.outputTable(configs as unknown as Record[], [ + { key: 'id', header: 'ID' }, + { key: 'agentType', header: 'Agent Type' }, + { key: 'projectId', header: 'Project', format: (v) => String(v ?? '(org)') }, + { key: 'model', header: 'Model' }, + { key: 'maxIterations', header: 'Max Iter' }, + { key: 'agentBackend', header: 'Backend' }, + ]); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/agents/update.ts b/src/cli/dashboard/agents/update.ts new file mode 100644 index 00000000..0453fb48 --- /dev/null +++ b/src/cli/dashboard/agents/update.ts @@ -0,0 +1,43 @@ +import { Args, Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +export default class AgentsUpdate extends DashboardCommand { + static override description = 'Update an agent configuration.'; + + static override args = { + id: Args.integer({ description: 'Agent config ID', required: true }), + }; + + static override flags = { + ...DashboardCommand.baseFlags, + 'agent-type': Flags.string({ description: 'Agent type' }), + model: Flags.string({ description: 'Model override' }), + 'max-iterations': Flags.integer({ description: 'Max iterations override' }), + backend: Flags.string({ description: 'Agent backend override' }), + prompt: Flags.string({ description: 'Custom prompt override' }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(AgentsUpdate); + + try { + await this.client.agentConfigs.update.mutate({ + id: args.id, + agentType: flags['agent-type'], + model: flags.model, + maxIterations: flags['max-iterations'], + agentBackend: flags.backend, + prompt: flags.prompt, + }); + + if (flags.json) { + this.outputJson({ ok: true }); + return; + } + + this.log(`Updated agent config #${args.id}`); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/credentials/create.ts b/src/cli/dashboard/credentials/create.ts new file mode 100644 index 00000000..4ffc07c8 --- /dev/null +++ b/src/cli/dashboard/credentials/create.ts @@ -0,0 +1,41 @@ +import { Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +export default class CredentialsCreate extends DashboardCommand { + static override description = 'Create a new credential.'; + + static override flags = { + ...DashboardCommand.baseFlags, + name: Flags.string({ description: 'Credential name', required: true }), + key: Flags.string({ + description: 'Environment variable key (e.g. GITHUB_TOKEN)', + required: true, + }), + value: Flags.string({ description: 'Credential value', required: true }), + description: Flags.string({ description: 'Optional description' }), + default: Flags.boolean({ description: 'Set as org default', default: false }), + }; + + async run(): Promise { + const { flags } = await this.parse(CredentialsCreate); + + try { + const result = await this.client.credentials.create.mutate({ + name: flags.name, + envVarKey: flags.key, + value: flags.value, + description: flags.description, + isDefault: flags.default, + }); + + if (flags.json) { + this.outputJson(result); + return; + } + + this.log(`Created credential: ${flags.name} (${flags.key})`); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/credentials/delete.ts b/src/cli/dashboard/credentials/delete.ts new file mode 100644 index 00000000..72d4ae44 --- /dev/null +++ b/src/cli/dashboard/credentials/delete.ts @@ -0,0 +1,36 @@ +import { Args, Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +export default class CredentialsDelete extends DashboardCommand { + static override description = 'Delete a credential.'; + + static override args = { + id: Args.integer({ description: 'Credential ID', required: true }), + }; + + static override flags = { + ...DashboardCommand.baseFlags, + yes: Flags.boolean({ description: 'Skip confirmation', char: 'y', default: false }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(CredentialsDelete); + + if (!flags.yes) { + this.error('Pass --yes to confirm deletion.'); + } + + try { + await this.client.credentials.delete.mutate({ id: args.id }); + + if (flags.json) { + this.outputJson({ ok: true }); + return; + } + + this.log(`Deleted credential #${args.id}`); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/credentials/list.ts b/src/cli/dashboard/credentials/list.ts new file mode 100644 index 00000000..e8e6d09f --- /dev/null +++ b/src/cli/dashboard/credentials/list.ts @@ -0,0 +1,33 @@ +import { DashboardCommand } from '../_shared/base.js'; +import { formatBoolean } from '../_shared/format.js'; + +export default class CredentialsList extends DashboardCommand { + static override description = 'List organization credentials (values masked).'; + + static override flags = { + ...DashboardCommand.baseFlags, + }; + + async run(): Promise { + const { flags } = await this.parse(CredentialsList); + + try { + const creds = await this.client.credentials.list.query(); + + if (flags.json) { + this.outputJson(creds); + return; + } + + this.outputTable(creds as unknown as Record[], [ + { key: 'id', header: 'ID' }, + { key: 'name', header: 'Name' }, + { key: 'envVarKey', header: 'Key' }, + { key: 'value', header: 'Value (masked)' }, + { key: 'isDefault', header: 'Default', format: formatBoolean }, + ]); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/credentials/update.ts b/src/cli/dashboard/credentials/update.ts new file mode 100644 index 00000000..81bb48d1 --- /dev/null +++ b/src/cli/dashboard/credentials/update.ts @@ -0,0 +1,41 @@ +import { Args, Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +export default class CredentialsUpdate extends DashboardCommand { + static override description = 'Update a credential.'; + + static override args = { + id: Args.integer({ description: 'Credential ID', required: true }), + }; + + static override flags = { + ...DashboardCommand.baseFlags, + name: Flags.string({ description: 'Credential name' }), + value: Flags.string({ description: 'Credential value' }), + description: Flags.string({ description: 'Description' }), + default: Flags.boolean({ description: 'Set as org default', allowNo: true }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(CredentialsUpdate); + + try { + await this.client.credentials.update.mutate({ + id: args.id, + name: flags.name, + value: flags.value, + description: flags.description, + isDefault: flags.default, + }); + + if (flags.json) { + this.outputJson({ ok: true }); + return; + } + + this.log(`Updated credential #${args.id}`); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/defaults/set.ts b/src/cli/dashboard/defaults/set.ts new file mode 100644 index 00000000..9832accf --- /dev/null +++ b/src/cli/dashboard/defaults/set.ts @@ -0,0 +1,46 @@ +import { Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +export default class DefaultsSet extends DashboardCommand { + static override description = 'Set organization defaults.'; + + static override flags = { + ...DashboardCommand.baseFlags, + model: Flags.string({ description: 'Default model' }), + 'max-iterations': Flags.integer({ description: 'Max iterations per agent run' }), + 'fresh-machine-timeout': Flags.integer({ description: 'Fresh machine timeout (ms)' }), + 'watchdog-timeout': Flags.integer({ description: 'Watchdog timeout (ms)' }), + 'post-job-grace': Flags.integer({ description: 'Post-job grace period (ms)' }), + 'card-budget': Flags.string({ description: 'Per-card budget in USD' }), + 'agent-backend': Flags.string({ description: 'Default agent backend' }), + 'progress-model': Flags.string({ description: 'Model for progress updates' }), + 'progress-interval': Flags.string({ description: 'Progress update interval (minutes)' }), + }; + + async run(): Promise { + const { flags } = await this.parse(DefaultsSet); + + try { + await this.client.defaults.upsert.mutate({ + model: flags.model, + maxIterations: flags['max-iterations'], + freshMachineTimeoutMs: flags['fresh-machine-timeout'], + watchdogTimeoutMs: flags['watchdog-timeout'], + postJobGracePeriodMs: flags['post-job-grace'], + cardBudgetUsd: flags['card-budget'], + agentBackend: flags['agent-backend'], + progressModel: flags['progress-model'], + progressIntervalMinutes: flags['progress-interval'], + }); + + if (flags.json) { + this.outputJson({ ok: true }); + return; + } + + this.log('Defaults updated.'); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/defaults/show.ts b/src/cli/dashboard/defaults/show.ts new file mode 100644 index 00000000..54d9cc2b --- /dev/null +++ b/src/cli/dashboard/defaults/show.ts @@ -0,0 +1,41 @@ +import { DashboardCommand } from '../_shared/base.js'; + +export default class DefaultsShow extends DashboardCommand { + static override description = 'Show organization defaults.'; + + static override flags = { + ...DashboardCommand.baseFlags, + }; + + async run(): Promise { + const { flags } = await this.parse(DefaultsShow); + + try { + const defaults = await this.client.defaults.get.query(); + + if (flags.json) { + this.outputJson(defaults); + return; + } + + if (!defaults) { + this.log('No defaults configured.'); + return; + } + + this.outputDetail(defaults as unknown as Record, { + model: { label: 'Model' }, + maxIterations: { label: 'Max Iterations' }, + freshMachineTimeoutMs: { label: 'Fresh Machine Timeout' }, + watchdogTimeoutMs: { label: 'Watchdog Timeout' }, + postJobGracePeriodMs: { label: 'Post-Job Grace' }, + cardBudgetUsd: { label: 'Card Budget' }, + agentBackend: { label: 'Agent Backend' }, + progressModel: { label: 'Progress Model' }, + progressIntervalMinutes: { label: 'Progress Interval' }, + }); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/login.ts b/src/cli/dashboard/login.ts new file mode 100644 index 00000000..47498d29 --- /dev/null +++ b/src/cli/dashboard/login.ts @@ -0,0 +1,50 @@ +import { Command, Flags } from '@oclif/core'; +import { saveConfig } from './_shared/config.js'; + +export default class Login extends Command { + static override description = 'Log in to the CASCADE dashboard.'; + + static override flags = { + server: Flags.string({ + description: 'Server URL (e.g. http://localhost:3000)', + required: true, + }), + email: Flags.string({ description: 'Login email', required: true }), + password: Flags.string({ description: 'Login password', required: true }), + }; + + async run(): Promise { + const { flags } = await this.parse(Login); + const serverUrl = flags.server.replace(/\/$/, ''); + + const response = await fetch(`${serverUrl}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: flags.email, password: flags.password }), + }); + + if (!response.ok) { + const body = (await response.json()) as { error?: string }; + this.error(body.error ?? `Login failed (${response.status})`); + } + + // Extract session token from Set-Cookie header + const setCookie = response.headers.get('set-cookie') ?? ''; + const match = setCookie.match(/cascade_session=([^;]+)/); + if (!match) { + this.error('Login succeeded but no session cookie received.'); + } + + saveConfig({ serverUrl, sessionToken: match[1] }); + + const user = (await response.json()) as { email: string; name: string }; + this.log(`Logged in as ${user.name} (${user.email})`); + + // Show if overrides are active + if (process.env.CASCADE_SERVER_URL || process.env.CASCADE_SESSION_TOKEN) { + this.log( + 'Note: CASCADE_SERVER_URL / CASCADE_SESSION_TOKEN env vars will override stored config.', + ); + } + } +} diff --git a/src/cli/dashboard/logout.ts b/src/cli/dashboard/logout.ts new file mode 100644 index 00000000..53e2adb0 --- /dev/null +++ b/src/cli/dashboard/logout.ts @@ -0,0 +1,24 @@ +import { Command } from '@oclif/core'; +import { clearConfig, loadConfig } from './_shared/config.js'; + +export default class Logout extends Command { + static override description = 'Log out of the CASCADE dashboard.'; + + async run(): Promise { + const config = loadConfig(); + if (config) { + // Best-effort server-side logout + try { + await fetch(`${config.serverUrl}/api/auth/logout`, { + method: 'POST', + headers: { Cookie: `cascade_session=${config.sessionToken}` }, + }); + } catch { + // Ignore — server may be unreachable + } + } + + clearConfig(); + this.log('Logged out.'); + } +} diff --git a/src/cli/dashboard/org/show.ts b/src/cli/dashboard/org/show.ts new file mode 100644 index 00000000..6ad46de1 --- /dev/null +++ b/src/cli/dashboard/org/show.ts @@ -0,0 +1,34 @@ +import { DashboardCommand } from '../_shared/base.js'; + +export default class OrgShow extends DashboardCommand { + static override description = 'Show organization info.'; + + static override flags = { + ...DashboardCommand.baseFlags, + }; + + async run(): Promise { + const { flags } = await this.parse(OrgShow); + + try { + const org = await this.client.organization.get.query(); + + if (flags.json) { + this.outputJson(org); + return; + } + + if (!org) { + this.log('Organization not found.'); + return; + } + + this.outputDetail(org as unknown as Record, { + id: { label: 'ID' }, + name: { label: 'Name' }, + }); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/org/update.ts b/src/cli/dashboard/org/update.ts new file mode 100644 index 00000000..9a945e1a --- /dev/null +++ b/src/cli/dashboard/org/update.ts @@ -0,0 +1,28 @@ +import { Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +export default class OrgUpdate extends DashboardCommand { + static override description = 'Update organization info.'; + + static override flags = { + ...DashboardCommand.baseFlags, + name: Flags.string({ description: 'Organization name', required: true }), + }; + + async run(): Promise { + const { flags } = await this.parse(OrgUpdate); + + try { + await this.client.organization.update.mutate({ name: flags.name }); + + if (flags.json) { + this.outputJson({ ok: true }); + return; + } + + this.log(`Organization updated: ${flags.name}`); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/projects/create.ts b/src/cli/dashboard/projects/create.ts new file mode 100644 index 00000000..678215d3 --- /dev/null +++ b/src/cli/dashboard/projects/create.ts @@ -0,0 +1,49 @@ +import { Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +export default class ProjectsCreate extends DashboardCommand { + static override description = 'Create a new project.'; + + static override flags = { + ...DashboardCommand.baseFlags, + id: Flags.string({ description: 'Project ID (lowercase, hyphens)', required: true }), + name: Flags.string({ description: 'Project name', required: true }), + repo: Flags.string({ description: 'GitHub repo (owner/name)', required: true }), + 'base-branch': Flags.string({ description: 'Base branch (default: main)' }), + 'branch-prefix': Flags.string({ description: 'Branch prefix' }), + model: Flags.string({ description: 'Default model' }), + 'card-budget': Flags.string({ description: 'Per-card budget in USD' }), + 'agent-backend': Flags.string({ description: 'Agent backend (e.g. claude-code)' }), + 'subscription-cost-zero': Flags.boolean({ + description: 'Zero costs for subscription backends', + allowNo: true, + }), + }; + + async run(): Promise { + const { flags } = await this.parse(ProjectsCreate); + + try { + const result = await this.client.projects.create.mutate({ + id: flags.id, + name: flags.name, + repo: flags.repo, + baseBranch: flags['base-branch'], + branchPrefix: flags['branch-prefix'], + model: flags.model, + cardBudgetUsd: flags['card-budget'], + agentBackend: flags['agent-backend'], + subscriptionCostZero: flags['subscription-cost-zero'], + }); + + if (flags.json) { + this.outputJson(result); + return; + } + + this.log(`Created project: ${flags.id}`); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/projects/delete.ts b/src/cli/dashboard/projects/delete.ts new file mode 100644 index 00000000..349925ee --- /dev/null +++ b/src/cli/dashboard/projects/delete.ts @@ -0,0 +1,36 @@ +import { Args, Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +export default class ProjectsDelete extends DashboardCommand { + static override description = 'Delete a project.'; + + static override args = { + id: Args.string({ description: 'Project ID', required: true }), + }; + + static override flags = { + ...DashboardCommand.baseFlags, + yes: Flags.boolean({ description: 'Skip confirmation', char: 'y', default: false }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(ProjectsDelete); + + if (!flags.yes) { + this.error('Pass --yes to confirm deletion.'); + } + + try { + await this.client.projects.delete.mutate({ id: args.id }); + + if (flags.json) { + this.outputJson({ ok: true }); + return; + } + + this.log(`Deleted project: ${args.id}`); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/projects/integration-set.ts b/src/cli/dashboard/projects/integration-set.ts new file mode 100644 index 00000000..26c4b216 --- /dev/null +++ b/src/cli/dashboard/projects/integration-set.ts @@ -0,0 +1,44 @@ +import { Args, Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +export default class ProjectsIntegrationSet extends DashboardCommand { + static override description = 'Create or update an integration config for a project.'; + + static override args = { + id: Args.string({ description: 'Project ID', required: true }), + }; + + static override flags = { + ...DashboardCommand.baseFlags, + type: Flags.string({ description: 'Integration type (e.g. trello)', required: true }), + config: Flags.string({ description: 'Config as JSON string', required: true }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(ProjectsIntegrationSet); + + let config: Record; + try { + config = JSON.parse(flags.config) as Record; + } catch { + this.error('Invalid JSON in --config flag.'); + } + + try { + await this.client.projects.integrations.upsert.mutate({ + projectId: args.id, + type: flags.type, + config, + }); + + if (flags.json) { + this.outputJson({ ok: true }); + return; + } + + this.log(`Set ${flags.type} integration for project: ${args.id}`); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/projects/integrations.ts b/src/cli/dashboard/projects/integrations.ts new file mode 100644 index 00000000..1dc9ebd9 --- /dev/null +++ b/src/cli/dashboard/projects/integrations.ts @@ -0,0 +1,41 @@ +import { Args } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +export default class ProjectsIntegrations extends DashboardCommand { + static override description = 'Show integration configs for a project.'; + + static override args = { + id: Args.string({ description: 'Project ID', required: true }), + }; + + static override flags = { + ...DashboardCommand.baseFlags, + }; + + async run(): Promise { + const { args, flags } = await this.parse(ProjectsIntegrations); + + try { + const integrations = await this.client.projects.integrations.list.query({ + projectId: args.id, + }); + + if (flags.json) { + this.outputJson(integrations); + return; + } + + if (!integrations || (Array.isArray(integrations) && integrations.length === 0)) { + this.log('No integrations configured.'); + return; + } + + for (const integration of integrations as unknown as Array>) { + this.log(`\nType: ${integration.type}`); + this.log(`Config: ${JSON.stringify(integration.config, null, 2)}`); + } + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/projects/list.ts b/src/cli/dashboard/projects/list.ts new file mode 100644 index 00000000..63a81129 --- /dev/null +++ b/src/cli/dashboard/projects/list.ts @@ -0,0 +1,33 @@ +import { DashboardCommand } from '../_shared/base.js'; + +export default class ProjectsList extends DashboardCommand { + static override description = 'List all projects.'; + + static override flags = { + ...DashboardCommand.baseFlags, + }; + + async run(): Promise { + const { flags } = await this.parse(ProjectsList); + + try { + const projects = await this.client.projects.listFull.query(); + + if (flags.json) { + this.outputJson(projects); + return; + } + + this.outputTable(projects as unknown as Record[], [ + { key: 'id', header: 'ID' }, + { key: 'name', header: 'Name' }, + { key: 'repo', header: 'Repo' }, + { key: 'baseBranch', header: 'Base Branch' }, + { key: 'model', header: 'Model' }, + { key: 'agentBackend', header: 'Backend' }, + ]); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/projects/override-rm.ts b/src/cli/dashboard/projects/override-rm.ts new file mode 100644 index 00000000..d2955172 --- /dev/null +++ b/src/cli/dashboard/projects/override-rm.ts @@ -0,0 +1,45 @@ +import { Args, Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +export default class ProjectsOverrideRm extends DashboardCommand { + static override description = 'Remove a credential override from a project.'; + + static override args = { + id: Args.string({ description: 'Project ID', required: true }), + }; + + static override flags = { + ...DashboardCommand.baseFlags, + key: Flags.string({ description: 'Environment variable key', required: true }), + 'agent-type': Flags.string({ description: 'Remove agent-scoped override' }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(ProjectsOverrideRm); + + try { + if (flags['agent-type']) { + await this.client.projects.credentialOverrides.removeAgent.mutate({ + projectId: args.id, + envVarKey: flags.key, + agentType: flags['agent-type'], + }); + } else { + await this.client.projects.credentialOverrides.remove.mutate({ + projectId: args.id, + envVarKey: flags.key, + }); + } + + if (flags.json) { + this.outputJson({ ok: true }); + return; + } + + const scope = flags['agent-type'] ? ` (agent: ${flags['agent-type']})` : ''; + this.log(`Removed override ${flags.key}${scope}`); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/projects/override-set.ts b/src/cli/dashboard/projects/override-set.ts new file mode 100644 index 00000000..9ff616b5 --- /dev/null +++ b/src/cli/dashboard/projects/override-set.ts @@ -0,0 +1,51 @@ +import { Args, Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +export default class ProjectsOverrideSet extends DashboardCommand { + static override description = 'Set a credential override for a project.'; + + static override args = { + id: Args.string({ description: 'Project ID', required: true }), + }; + + static override flags = { + ...DashboardCommand.baseFlags, + key: Flags.string({ + description: 'Environment variable key (e.g. GITHUB_TOKEN)', + required: true, + }), + 'credential-id': Flags.integer({ description: 'Credential ID to use', required: true }), + 'agent-type': Flags.string({ description: 'Scope to specific agent type' }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(ProjectsOverrideSet); + + try { + if (flags['agent-type']) { + await this.client.projects.credentialOverrides.setAgent.mutate({ + projectId: args.id, + envVarKey: flags.key, + agentType: flags['agent-type'], + credentialId: flags['credential-id'], + }); + } else { + await this.client.projects.credentialOverrides.set.mutate({ + projectId: args.id, + envVarKey: flags.key, + credentialId: flags['credential-id'], + }); + } + + if (flags.json) { + this.outputJson({ ok: true }); + return; + } + + const scope = flags['agent-type'] ? ` (agent: ${flags['agent-type']})` : ''; + this.log(`Set override ${flags.key} → credential #${flags['credential-id']}${scope}`); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/projects/overrides.ts b/src/cli/dashboard/projects/overrides.ts new file mode 100644 index 00000000..70c8acba --- /dev/null +++ b/src/cli/dashboard/projects/overrides.ts @@ -0,0 +1,42 @@ +import { Args } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +export default class ProjectsOverrides extends DashboardCommand { + static override description = 'Show credential overrides for a project.'; + + static override args = { + id: Args.string({ description: 'Project ID', required: true }), + }; + + static override flags = { + ...DashboardCommand.baseFlags, + }; + + async run(): Promise { + const { args, flags } = await this.parse(ProjectsOverrides); + + try { + const overrides = await this.client.projects.credentialOverrides.list.query({ + projectId: args.id, + }); + + if (flags.json) { + this.outputJson(overrides); + return; + } + + if (!overrides || (Array.isArray(overrides) && overrides.length === 0)) { + this.log('No credential overrides configured.'); + return; + } + + this.outputTable(overrides as unknown as Record[], [ + { key: 'envVarKey', header: 'Key' }, + { key: 'credentialId', header: 'Credential ID' }, + { key: 'agentType', header: 'Agent Type', format: (v) => String(v ?? '(all)') }, + ]); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/projects/show.ts b/src/cli/dashboard/projects/show.ts new file mode 100644 index 00000000..4f588719 --- /dev/null +++ b/src/cli/dashboard/projects/show.ts @@ -0,0 +1,42 @@ +import { Args } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; +import { formatBoolean } from '../_shared/format.js'; + +export default class ProjectsShow extends DashboardCommand { + static override description = 'Show project details.'; + + static override args = { + id: Args.string({ description: 'Project ID', required: true }), + }; + + static override flags = { + ...DashboardCommand.baseFlags, + }; + + async run(): Promise { + const { args, flags } = await this.parse(ProjectsShow); + + try { + const project = await this.client.projects.getById.query({ id: args.id }); + + if (flags.json) { + this.outputJson(project); + return; + } + + this.outputDetail(project as unknown as Record, { + id: { label: 'ID' }, + name: { label: 'Name' }, + repo: { label: 'Repo' }, + baseBranch: { label: 'Base Branch' }, + branchPrefix: { label: 'Branch Prefix' }, + model: { label: 'Model' }, + cardBudgetUsd: { label: 'Card Budget' }, + agentBackend: { label: 'Backend' }, + subscriptionCostZero: { label: 'Sub Cost Zero', format: formatBoolean }, + }); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/projects/update.ts b/src/cli/dashboard/projects/update.ts new file mode 100644 index 00000000..823805bc --- /dev/null +++ b/src/cli/dashboard/projects/update.ts @@ -0,0 +1,52 @@ +import { Args, Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +export default class ProjectsUpdate extends DashboardCommand { + static override description = 'Update a project.'; + + static override args = { + id: Args.string({ description: 'Project ID', required: true }), + }; + + static override flags = { + ...DashboardCommand.baseFlags, + name: Flags.string({ description: 'Project name' }), + repo: Flags.string({ description: 'GitHub repo (owner/name)' }), + 'base-branch': Flags.string({ description: 'Base branch' }), + 'branch-prefix': Flags.string({ description: 'Branch prefix' }), + model: Flags.string({ description: 'Default model' }), + 'card-budget': Flags.string({ description: 'Per-card budget in USD' }), + 'agent-backend': Flags.string({ description: 'Agent backend' }), + 'subscription-cost-zero': Flags.boolean({ + description: 'Zero costs for subscription backends', + allowNo: true, + }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(ProjectsUpdate); + + try { + await this.client.projects.update.mutate({ + id: args.id, + name: flags.name, + repo: flags.repo, + baseBranch: flags['base-branch'], + branchPrefix: flags['branch-prefix'], + model: flags.model, + cardBudgetUsd: flags['card-budget'], + agentBackend: flags['agent-backend'], + subscriptionCostZero: flags['subscription-cost-zero'], + }); + + if (flags.json) { + this.outputJson({ ok: true }); + return; + } + + this.log(`Updated project: ${args.id}`); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/runs/debug.ts b/src/cli/dashboard/runs/debug.ts new file mode 100644 index 00000000..56a0b3d8 --- /dev/null +++ b/src/cli/dashboard/runs/debug.ts @@ -0,0 +1,36 @@ +import { Args } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +export default class RunsDebug extends DashboardCommand { + static override description = 'Show debug analysis for an agent run.'; + + static override args = { + id: Args.string({ description: 'Run ID (UUID)', required: true }), + }; + + static override flags = { + ...DashboardCommand.baseFlags, + }; + + async run(): Promise { + const { args, flags } = await this.parse(RunsDebug); + + try { + const analysis = await this.client.runs.getDebugAnalysis.query({ runId: args.id }); + + if (flags.json) { + this.outputJson(analysis); + return; + } + + if (!analysis) { + this.log('No debug analysis found for this run.'); + return; + } + + console.log(JSON.stringify(analysis, null, 2)); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/runs/list.ts b/src/cli/dashboard/runs/list.ts new file mode 100644 index 00000000..2ed2654d --- /dev/null +++ b/src/cli/dashboard/runs/list.ts @@ -0,0 +1,55 @@ +import { Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; +import { formatCost, formatDate, formatDuration, formatStatus } from '../_shared/format.js'; + +export default class RunsList extends DashboardCommand { + static override description = 'List agent runs.'; + + static override flags = { + ...DashboardCommand.baseFlags, + project: Flags.string({ description: 'Filter by project ID' }), + status: Flags.string({ description: 'Filter by status (comma-separated)' }), + 'agent-type': Flags.string({ description: 'Filter by agent type' }), + limit: Flags.integer({ description: 'Number of results', default: 50 }), + offset: Flags.integer({ description: 'Offset for pagination', default: 0 }), + sort: Flags.string({ + description: 'Sort field', + options: ['startedAt', 'durationMs', 'costUsd'], + default: 'startedAt', + }), + order: Flags.string({ description: 'Sort order', options: ['asc', 'desc'], default: 'desc' }), + }; + + async run(): Promise { + const { flags } = await this.parse(RunsList); + + try { + const runs = await this.client.runs.list.query({ + projectId: flags.project, + status: flags.status?.split(','), + agentType: flags['agent-type'], + limit: flags.limit, + offset: flags.offset, + sort: flags.sort as 'startedAt' | 'durationMs' | 'costUsd', + order: flags.order as 'asc' | 'desc', + }); + + if (flags.json) { + this.outputJson(runs); + return; + } + + this.outputTable(runs as unknown as Record[], [ + { key: 'id', header: 'ID', format: (v) => String(v ?? '').slice(0, 8) }, + { key: 'projectId', header: 'Project' }, + { key: 'agentType', header: 'Agent' }, + { key: 'status', header: 'Status', format: formatStatus }, + { key: 'startedAt', header: 'Started', format: formatDate }, + { key: 'durationMs', header: 'Duration', format: formatDuration }, + { key: 'costUsd', header: 'Cost', format: formatCost }, + ]); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/runs/llm-call.ts b/src/cli/dashboard/runs/llm-call.ts new file mode 100644 index 00000000..26a15f5c --- /dev/null +++ b/src/cli/dashboard/runs/llm-call.ts @@ -0,0 +1,36 @@ +import { Args } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +export default class RunsLlmCall extends DashboardCommand { + static override description = 'Show a specific LLM call from an agent run.'; + + static override args = { + id: Args.string({ description: 'Run ID (UUID)', required: true }), + callNumber: Args.integer({ description: 'LLM call number', required: true }), + }; + + static override flags = { + ...DashboardCommand.baseFlags, + }; + + async run(): Promise { + const { args, flags } = await this.parse(RunsLlmCall); + + try { + const call = await this.client.runs.getLlmCall.query({ + runId: args.id, + callNumber: args.callNumber, + }); + + if (flags.json) { + this.outputJson(call); + return; + } + + // Pretty-print the full LLM call details + console.log(JSON.stringify(call, null, 2)); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/runs/llm-calls.ts b/src/cli/dashboard/runs/llm-calls.ts new file mode 100644 index 00000000..d2ca7d3a --- /dev/null +++ b/src/cli/dashboard/runs/llm-calls.ts @@ -0,0 +1,39 @@ +import { Args } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; +import { formatCost, formatDuration } from '../_shared/format.js'; + +export default class RunsLlmCalls extends DashboardCommand { + static override description = 'List LLM calls for an agent run.'; + + static override args = { + id: Args.string({ description: 'Run ID (UUID)', required: true }), + }; + + static override flags = { + ...DashboardCommand.baseFlags, + }; + + async run(): Promise { + const { args, flags } = await this.parse(RunsLlmCalls); + + try { + const calls = await this.client.runs.listLlmCalls.query({ runId: args.id }); + + if (flags.json) { + this.outputJson(calls); + return; + } + + this.outputTable(calls as unknown as Record[], [ + { key: 'callNumber', header: '#' }, + { key: 'model', header: 'Model' }, + { key: 'inputTokens', header: 'In Tokens' }, + { key: 'outputTokens', header: 'Out Tokens' }, + { key: 'durationMs', header: 'Duration', format: formatDuration }, + { key: 'costUsd', header: 'Cost', format: formatCost }, + ]); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/runs/logs.ts b/src/cli/dashboard/runs/logs.ts new file mode 100644 index 00000000..9658116a --- /dev/null +++ b/src/cli/dashboard/runs/logs.ts @@ -0,0 +1,45 @@ +import { Args } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +export default class RunsLogs extends DashboardCommand { + static override description = 'Show logs for an agent run.'; + + static override args = { + id: Args.string({ description: 'Run ID (UUID)', required: true }), + }; + + static override flags = { + ...DashboardCommand.baseFlags, + }; + + async run(): Promise { + const { args, flags } = await this.parse(RunsLogs); + + try { + const logs = await this.client.runs.getLogs.query({ runId: args.id }); + + if (flags.json) { + this.outputJson(logs); + return; + } + + if (!logs || (Array.isArray(logs) && logs.length === 0)) { + this.log('No logs found.'); + return; + } + + // Output raw log content for piping + if (typeof logs === 'string') { + console.log(logs); + } else if (Array.isArray(logs)) { + for (const entry of logs) { + console.log(typeof entry === 'string' ? entry : JSON.stringify(entry)); + } + } else { + console.log(JSON.stringify(logs, null, 2)); + } + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/runs/show.ts b/src/cli/dashboard/runs/show.ts new file mode 100644 index 00000000..e0ae9458 --- /dev/null +++ b/src/cli/dashboard/runs/show.ts @@ -0,0 +1,45 @@ +import { Args } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; +import { formatCost, formatDate, formatDuration, formatStatus } from '../_shared/format.js'; + +export default class RunsShow extends DashboardCommand { + static override description = 'Show details of an agent run.'; + + static override args = { + id: Args.string({ description: 'Run ID (UUID)', required: true }), + }; + + static override flags = { + ...DashboardCommand.baseFlags, + }; + + async run(): Promise { + const { args, flags } = await this.parse(RunsShow); + + try { + const run = await this.client.runs.getById.query({ id: args.id }); + + if (flags.json) { + this.outputJson(run); + return; + } + + this.outputDetail(run as unknown as Record, { + id: { label: 'ID' }, + projectId: { label: 'Project' }, + agentType: { label: 'Agent Type' }, + status: { label: 'Status', format: formatStatus }, + startedAt: { label: 'Started', format: formatDate }, + durationMs: { label: 'Duration', format: formatDuration }, + costUsd: { label: 'Cost', format: formatCost }, + cardId: { label: 'Card ID' }, + cardName: { label: 'Card Name' }, + iterations: { label: 'Iterations' }, + llmCalls: { label: 'LLM Calls' }, + errorMessage: { label: 'Error' }, + }); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/webhooks/create.ts b/src/cli/dashboard/webhooks/create.ts new file mode 100644 index 00000000..efd1a4c3 --- /dev/null +++ b/src/cli/dashboard/webhooks/create.ts @@ -0,0 +1,53 @@ +import { Args, Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +export default class WebhooksCreate extends DashboardCommand { + static override description = 'Create webhooks for a project.'; + + static override args = { + projectId: Args.string({ description: 'Project ID', required: true }), + }; + + static override flags = { + ...DashboardCommand.baseFlags, + 'callback-url': Flags.string({ description: 'Callback base URL', required: true }), + 'trello-only': Flags.boolean({ description: 'Only create Trello webhook', default: false }), + 'github-only': Flags.boolean({ description: 'Only create GitHub webhook', default: false }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(WebhooksCreate); + + try { + const result = await this.client.webhooks.create.mutate({ + projectId: args.projectId, + callbackBaseUrl: flags['callback-url'], + trelloOnly: flags['trello-only'], + githubOnly: flags['github-only'], + }); + + if (flags.json) { + this.outputJson(result); + return; + } + + if (result.trello) { + if (typeof result.trello === 'string') { + this.log(`Trello: ${result.trello}`); + } else { + this.log(`Created Trello webhook: [${result.trello.id}] ${result.trello.callbackURL}`); + } + } + + if (result.github) { + if (typeof result.github === 'string') { + this.log(`GitHub: ${result.github}`); + } else { + this.log(`Created GitHub webhook: [${result.github.id}] ${result.github.config.url}`); + } + } + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/webhooks/delete.ts b/src/cli/dashboard/webhooks/delete.ts new file mode 100644 index 00000000..79de244f --- /dev/null +++ b/src/cli/dashboard/webhooks/delete.ts @@ -0,0 +1,49 @@ +import { Args, Flags } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +export default class WebhooksDelete extends DashboardCommand { + static override description = 'Delete webhooks for a project.'; + + static override args = { + projectId: Args.string({ description: 'Project ID', required: true }), + }; + + static override flags = { + ...DashboardCommand.baseFlags, + 'callback-url': Flags.string({ description: 'Callback base URL', required: true }), + 'trello-only': Flags.boolean({ description: 'Only delete Trello webhooks', default: false }), + 'github-only': Flags.boolean({ description: 'Only delete GitHub webhooks', default: false }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(WebhooksDelete); + + try { + const result = await this.client.webhooks.delete.mutate({ + projectId: args.projectId, + callbackBaseUrl: flags['callback-url'], + trelloOnly: flags['trello-only'], + githubOnly: flags['github-only'], + }); + + if (flags.json) { + this.outputJson(result); + return; + } + + if (result.trello.length > 0) { + this.log(`Deleted ${result.trello.length} Trello webhook(s): ${result.trello.join(', ')}`); + } else { + this.log('No matching Trello webhooks found.'); + } + + if (result.github.length > 0) { + this.log(`Deleted ${result.github.length} GitHub webhook(s): ${result.github.join(', ')}`); + } else { + this.log('No matching GitHub webhooks found.'); + } + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/webhooks/list.ts b/src/cli/dashboard/webhooks/list.ts new file mode 100644 index 00000000..c9c50ce5 --- /dev/null +++ b/src/cli/dashboard/webhooks/list.ts @@ -0,0 +1,51 @@ +import { Args } from '@oclif/core'; +import { DashboardCommand } from '../_shared/base.js'; + +export default class WebhooksList extends DashboardCommand { + static override description = 'List Trello and GitHub webhooks for a project.'; + + static override args = { + projectId: Args.string({ description: 'Project ID', required: true }), + }; + + static override flags = { + ...DashboardCommand.baseFlags, + }; + + async run(): Promise { + const { args, flags } = await this.parse(WebhooksList); + + try { + const result = await this.client.webhooks.list.query({ projectId: args.projectId }); + + if (flags.json) { + this.outputJson(result); + return; + } + + this.log('Trello webhooks:'); + if (result.trello.length === 0) { + this.log(' (none)'); + } else { + for (const w of result.trello) { + this.log(` [${w.id}] ${w.callbackURL} (active: ${w.active})`); + if (w.description) this.log(` ${w.description}`); + } + } + + this.log(''); + this.log('GitHub webhooks:'); + if (result.github.length === 0) { + this.log(' (none)'); + } else { + for (const w of result.github) { + this.log( + ` [${w.id}] ${w.config.url} (active: ${w.active}, events: ${w.events.join(', ')})`, + ); + } + } + } catch (err) { + this.handleError(err); + } + } +} diff --git a/src/cli/dashboard/whoami.ts b/src/cli/dashboard/whoami.ts new file mode 100644 index 00000000..6de3eb2f --- /dev/null +++ b/src/cli/dashboard/whoami.ts @@ -0,0 +1,31 @@ +import { DashboardCommand } from './_shared/base.js'; + +export default class Whoami extends DashboardCommand { + static override description = 'Show current logged-in user.'; + + static override flags = { + ...DashboardCommand.baseFlags, + }; + + async run(): Promise { + const { flags } = await this.parse(Whoami); + + try { + const user = await this.client.auth.me.query(); + + if (flags.json) { + this.outputJson(user); + return; + } + + this.outputDetail(user as unknown as Record, { + name: { label: 'Name' }, + email: { label: 'Email' }, + role: { label: 'Role' }, + orgId: { label: 'Org' }, + }); + } catch (err) { + this.handleError(err); + } + } +} diff --git a/tests/unit/api/router.test.ts b/tests/unit/api/router.test.ts index 69f07007..759b7a59 100644 --- a/tests/unit/api/router.test.ts +++ b/tests/unit/api/router.test.ts @@ -50,6 +50,21 @@ vi.mock('../../../src/db/repositories/credentialsRepository.js', () => ({ removeProjectCredentialOverride: vi.fn(), setAgentCredentialOverride: vi.fn(), removeAgentCredentialOverride: vi.fn(), + resolveAllCredentials: vi.fn(), +})); + +vi.mock('../../../src/db/repositories/configRepository.js', () => ({ + findProjectByIdFromDb: vi.fn(), +})); + +vi.mock('@octokit/rest', () => ({ + Octokit: vi.fn(() => ({ + repos: { + listWebhooks: vi.fn(), + createWebhook: vi.fn(), + deleteWebhook: vi.fn(), + }, + })), })); import { appRouter } from '../../../src/api/router.js'; @@ -115,4 +130,11 @@ describe('appRouter', () => { expect(procedures).toContain('agentConfigs.update'); expect(procedures).toContain('agentConfigs.delete'); }); + + it('has webhooks sub-router with all procedures', () => { + const procedures = Object.keys(appRouter._def.procedures); + expect(procedures).toContain('webhooks.list'); + expect(procedures).toContain('webhooks.create'); + expect(procedures).toContain('webhooks.delete'); + }); }); diff --git a/tests/unit/api/routers/webhooks.test.ts b/tests/unit/api/routers/webhooks.test.ts new file mode 100644 index 00000000..9647f7d6 --- /dev/null +++ b/tests/unit/api/routers/webhooks.test.ts @@ -0,0 +1,432 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { TRPCContext } from '../../../../src/api/trpc.js'; + +// --- Mock dependencies --- + +const mockFindProjectByIdFromDb = vi.fn(); +const mockResolveAllCredentials = vi.fn(); + +const mockDbSelect = vi.fn(); +const mockDbFrom = vi.fn(); +const mockDbWhere = vi.fn(); + +vi.mock('../../../../src/db/client.js', () => ({ + getDb: () => ({ + select: mockDbSelect, + }), +})); + +vi.mock('../../../../src/db/schema/index.js', () => ({ + projects: { id: 'id', orgId: 'org_id' }, +})); + +vi.mock('../../../../src/db/repositories/configRepository.js', () => ({ + findProjectByIdFromDb: (...args: unknown[]) => mockFindProjectByIdFromDb(...args), +})); + +vi.mock('../../../../src/db/repositories/credentialsRepository.js', () => ({ + resolveAllCredentials: (...args: unknown[]) => mockResolveAllCredentials(...args), +})); + +// Mock global fetch for Trello API calls +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +// Mock Octokit for GitHub API calls +const mockListWebhooks = vi.fn(); +const mockCreateWebhook = vi.fn(); +const mockDeleteWebhook = vi.fn(); + +vi.mock('@octokit/rest', () => ({ + Octokit: vi.fn(() => ({ + repos: { + listWebhooks: mockListWebhooks, + createWebhook: mockCreateWebhook, + deleteWebhook: mockDeleteWebhook, + }, + })), +})); + +import { webhooksRouter } from '../../../../src/api/routers/webhooks.js'; + +function createCaller(ctx: TRPCContext) { + return webhooksRouter.createCaller(ctx); +} + +const mockUser = { + id: 'user-1', + orgId: 'org-1', + email: 'test@example.com', + name: 'Test', + role: 'admin', +}; + +const mockProject = { + id: 'my-project', + orgId: 'org-1', + repo: 'owner/repo', + trello: { boardId: 'board-123' }, +}; + +function setupProjectContext(opts?: { noTrello?: boolean; noGithub?: boolean }) { + mockDbSelect.mockReturnValue({ from: mockDbFrom }); + mockDbFrom.mockReturnValue({ where: mockDbWhere }); + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockFindProjectByIdFromDb.mockResolvedValue(mockProject); + mockResolveAllCredentials.mockResolvedValue({ + TRELLO_API_KEY: opts?.noTrello ? undefined : 'trello-key', + TRELLO_TOKEN: opts?.noTrello ? undefined : 'trello-token', + GITHUB_TOKEN: opts?.noGithub ? undefined : 'ghp_test123', + }); +} + +describe('webhooksRouter', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('list', () => { + it('returns trello and github webhooks', async () => { + setupProjectContext(); + + const trelloWebhooks = [ + { + id: 'tw-1', + description: 'test', + idModel: 'board-123', + callbackURL: 'http://x', + active: true, + }, + ]; + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(trelloWebhooks), + }); + + const githubWebhooks = [ + { id: 1, name: 'web', active: true, events: ['push'], config: { url: 'http://y' } }, + ]; + mockListWebhooks.mockResolvedValue({ data: githubWebhooks }); + + const caller = createCaller({ user: mockUser }); + const result = await caller.list({ projectId: 'my-project' }); + + expect(result.trello).toHaveLength(1); + expect(result.trello[0].id).toBe('tw-1'); + expect(result.github).toHaveLength(1); + expect(result.github[0].id).toBe(1); + }); + + it('filters trello webhooks by board ID', async () => { + setupProjectContext(); + + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve([ + { id: 'tw-1', idModel: 'board-123', callbackURL: 'http://x', active: true }, + { id: 'tw-2', idModel: 'other-board', callbackURL: 'http://y', active: true }, + ]), + }); + mockListWebhooks.mockResolvedValue({ data: [] }); + + const caller = createCaller({ user: mockUser }); + const result = await caller.list({ projectId: 'my-project' }); + + expect(result.trello).toHaveLength(1); + expect(result.trello[0].id).toBe('tw-1'); + }); + + it('returns empty arrays when no credentials', async () => { + setupProjectContext({ noTrello: true, noGithub: true }); + + const caller = createCaller({ user: mockUser }); + const result = await caller.list({ projectId: 'my-project' }); + + expect(result.trello).toEqual([]); + expect(result.github).toEqual([]); + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockListWebhooks).not.toHaveBeenCalled(); + }); + + it('throws NOT_FOUND when project belongs to different org', async () => { + mockDbSelect.mockReturnValue({ from: mockDbFrom }); + mockDbFrom.mockReturnValue({ where: mockDbWhere }); + mockDbWhere.mockResolvedValue([{ orgId: 'different-org' }]); + + const caller = createCaller({ user: mockUser }); + await expect(caller.list({ projectId: 'my-project' })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); + + it('throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null }); + await expect(caller.list({ projectId: 'my-project' })).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + }); + + describe('create', () => { + it('creates both trello and github webhooks', async () => { + setupProjectContext(); + + // First fetch: trello list (for duplicate check) - empty + // Second fetch: trello create + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve([]), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + id: 'tw-new', + callbackURL: 'http://example.com/webhook/trello', + idModel: 'board-123', + active: true, + }), + }); + + mockListWebhooks.mockResolvedValue({ data: [] }); + mockCreateWebhook.mockResolvedValue({ + data: { + id: 42, + config: { url: 'http://example.com/webhook/github' }, + events: ['pull_request'], + active: true, + }, + }); + + const caller = createCaller({ user: mockUser }); + const result = await caller.create({ + projectId: 'my-project', + callbackBaseUrl: 'http://example.com', + }); + + expect(result.trello).toMatchObject({ id: 'tw-new' }); + expect(result.github).toMatchObject({ id: 42 }); + }); + + it('returns duplicate message when webhook already exists', async () => { + setupProjectContext(); + + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve([ + { + id: 'tw-existing', + callbackURL: 'http://example.com/webhook/trello', + idModel: 'board-123', + active: true, + }, + ]), + }); + + mockListWebhooks.mockResolvedValue({ + data: [ + { + id: 99, + config: { url: 'http://example.com/webhook/github' }, + events: ['push'], + active: true, + }, + ], + }); + + const caller = createCaller({ user: mockUser }); + const result = await caller.create({ + projectId: 'my-project', + callbackBaseUrl: 'http://example.com', + }); + + expect(result.trello).toBe('Already exists: tw-existing'); + expect(result.github).toBe('Already exists: 99'); + }); + + it('strips trailing slash from callback URL', async () => { + setupProjectContext({ noTrello: true }); + + mockListWebhooks.mockResolvedValue({ data: [] }); + mockCreateWebhook.mockResolvedValue({ + data: { + id: 1, + config: { url: 'http://example.com/webhook/github' }, + events: [], + active: true, + }, + }); + + const caller = createCaller({ user: mockUser }); + await caller.create({ + projectId: 'my-project', + callbackBaseUrl: 'http://example.com/', + }); + + expect(mockCreateWebhook).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + url: 'http://example.com/webhook/github', + }), + }), + ); + }); + + it('respects trelloOnly flag', async () => { + setupProjectContext(); + + mockFetch + .mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([]) }) + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + id: 'tw-new', + callbackURL: 'http://example.com/webhook/trello', + idModel: 'board-123', + active: true, + }), + }); + + const caller = createCaller({ user: mockUser }); + const result = await caller.create({ + projectId: 'my-project', + callbackBaseUrl: 'http://example.com', + trelloOnly: true, + }); + + expect(result.trello).toMatchObject({ id: 'tw-new' }); + expect(result.github).toBeUndefined(); + expect(mockCreateWebhook).not.toHaveBeenCalled(); + }); + + it('respects githubOnly flag', async () => { + setupProjectContext(); + + mockListWebhooks.mockResolvedValue({ data: [] }); + mockCreateWebhook.mockResolvedValue({ + data: { + id: 1, + config: { url: 'http://example.com/webhook/github' }, + events: [], + active: true, + }, + }); + + const caller = createCaller({ user: mockUser }); + const result = await caller.create({ + projectId: 'my-project', + callbackBaseUrl: 'http://example.com', + githubOnly: true, + }); + + expect(result.trello).toBeUndefined(); + expect(result.github).toMatchObject({ id: 1 }); + // No Trello fetch calls (only GitHub Octokit calls) + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); + + describe('delete', () => { + it('deletes matching trello and github webhooks', async () => { + setupProjectContext(); + + mockFetch + // trello list + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve([ + { + id: 'tw-1', + callbackURL: 'http://example.com/webhook/trello', + idModel: 'board-123', + active: true, + }, + ]), + }) + // trello delete + .mockResolvedValueOnce({ ok: true }); + + mockListWebhooks.mockResolvedValue({ + data: [ + { + id: 10, + config: { url: 'http://example.com/webhook/github' }, + events: [], + active: true, + }, + ], + }); + mockDeleteWebhook.mockResolvedValue({}); + + const caller = createCaller({ user: mockUser }); + const result = await caller.delete({ + projectId: 'my-project', + callbackBaseUrl: 'http://example.com', + }); + + expect(result.trello).toEqual(['tw-1']); + expect(result.github).toEqual([10]); + }); + + it('returns empty arrays when no matching webhooks', async () => { + setupProjectContext(); + + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve([]), + }); + mockListWebhooks.mockResolvedValue({ data: [] }); + + const caller = createCaller({ user: mockUser }); + const result = await caller.delete({ + projectId: 'my-project', + callbackBaseUrl: 'http://example.com', + }); + + expect(result.trello).toEqual([]); + expect(result.github).toEqual([]); + }); + + it('deletes multiple matching webhooks', async () => { + setupProjectContext(); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve([ + { + id: 'tw-1', + callbackURL: 'http://example.com/webhook/trello', + idModel: 'board-123', + active: true, + }, + { + id: 'tw-2', + callbackURL: 'http://example.com/webhook/trello', + idModel: 'board-123', + active: true, + }, + ]), + }) + // Two delete calls + .mockResolvedValueOnce({ ok: true }) + .mockResolvedValueOnce({ ok: true }); + + mockListWebhooks.mockResolvedValue({ data: [] }); + + const caller = createCaller({ user: mockUser }); + const result = await caller.delete({ + projectId: 'my-project', + callbackBaseUrl: 'http://example.com', + }); + + expect(result.trello).toEqual(['tw-1', 'tw-2']); + }); + }); +}); diff --git a/tests/unit/cli/dashboard/base.test.ts b/tests/unit/cli/dashboard/base.test.ts new file mode 100644 index 00000000..6291db81 --- /dev/null +++ b/tests/unit/cli/dashboard/base.test.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockLoadConfig = vi.fn(); +const mockCreateDashboardClient = vi.fn(); + +vi.mock('../../../../src/cli/dashboard/_shared/config.js', () => ({ + loadConfig: (...args: unknown[]) => mockLoadConfig(...args), +})); + +vi.mock('../../../../src/cli/dashboard/_shared/client.js', () => ({ + createDashboardClient: (...args: unknown[]) => mockCreateDashboardClient(...args), +})); + +vi.mock('chalk', () => ({ + default: { + bold: (s: string) => s, + blue: (s: string) => s, + green: (s: string) => s, + red: (s: string) => s, + yellow: (s: string) => s, + dim: (s: string) => s, + }, +})); + +import { DashboardCommand } from '../../../../src/cli/dashboard/_shared/base.js'; + +// Concrete subclass for testing +class TestCommand extends DashboardCommand { + static override id = 'test'; + static override description = 'Test command'; + + async run(): Promise { + // Access client to trigger lazy initialization + const _client = this.client; + } +} + +class TestErrorCommand extends DashboardCommand { + static override id = 'test-error'; + static override description = 'Test error command'; + + errorToThrow: unknown = null; + + async run(): Promise { + this.handleError(this.errorToThrow as Error); + } +} + +describe('DashboardCommand', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('config loading', () => { + it('errors when not logged in (no config)', async () => { + mockLoadConfig.mockReturnValue(null); + + const cmd = new TestCommand([], {} as never); + await expect(cmd.run()).rejects.toThrow('Not logged in'); + }); + + it('creates client from loaded config', async () => { + const config = { serverUrl: 'http://localhost:3000', sessionToken: 'tok' }; + mockLoadConfig.mockReturnValue(config); + mockCreateDashboardClient.mockReturnValue({}); + + const cmd = new TestCommand([], {} as never); + await cmd.run(); + + expect(mockCreateDashboardClient).toHaveBeenCalledWith(config); + }); + }); + + describe('outputJson', () => { + it('prints JSON to console', () => { + mockLoadConfig.mockReturnValue({ serverUrl: 'x', sessionToken: 'y' }); + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const cmd = new TestCommand([], {} as never); + cmd.outputJson({ hello: 'world' }); + + expect(consoleSpy).toHaveBeenCalledWith(JSON.stringify({ hello: 'world' }, null, 2)); + consoleSpy.mockRestore(); + }); + }); + + describe('handleError', () => { + it('shows login message for UNAUTHORIZED tRPC errors', async () => { + mockLoadConfig.mockReturnValue({ serverUrl: 'x', sessionToken: 'y' }); + // Simulate TRPCClientError shape + const err = Object.assign(new Error('UNAUTHORIZED'), { + data: { code: 'UNAUTHORIZED' }, + }); + // Manually set constructor name to match instanceof check + Object.defineProperty(err.constructor, 'name', { value: 'TRPCClientError' }); + + const cmd = new TestErrorCommand([], {} as never); + cmd.errorToThrow = err; + + // handleError calls this.error() which throws oclif CLIError + await expect(cmd.run()).rejects.toThrow(); + }); + + it('rethrows non-TRPCClientError errors', async () => { + mockLoadConfig.mockReturnValue({ serverUrl: 'x', sessionToken: 'y' }); + const err = new TypeError('something else'); + + const cmd = new TestErrorCommand([], {} as never); + cmd.errorToThrow = err; + + await expect(cmd.run()).rejects.toThrow('something else'); + }); + }); +}); diff --git a/tests/unit/cli/dashboard/client.test.ts b/tests/unit/cli/dashboard/client.test.ts new file mode 100644 index 00000000..0fdc8f47 --- /dev/null +++ b/tests/unit/cli/dashboard/client.test.ts @@ -0,0 +1,59 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@trpc/client', () => ({ + createTRPCClient: vi.fn(() => ({ mock: 'client' })), + httpBatchLink: vi.fn((opts) => ({ type: 'httpBatchLink', ...opts })), +})); + +import { createTRPCClient, httpBatchLink } from '@trpc/client'; +import { createDashboardClient } from '../../../../src/cli/dashboard/_shared/client.js'; + +describe('createDashboardClient', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('creates a tRPC client with links', () => { + const config = { serverUrl: 'http://localhost:3000', sessionToken: 'my-token' }; + + createDashboardClient(config); + + expect(createTRPCClient).toHaveBeenCalledTimes(1); + const callArgs = vi.mocked(createTRPCClient).mock.calls[0][0]; + expect(callArgs.links).toHaveLength(1); + }); + + it('configures httpBatchLink with /trpc endpoint', () => { + const config = { serverUrl: 'http://myserver:4000', sessionToken: 'tok' }; + + createDashboardClient(config); + + expect(httpBatchLink).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'http://myserver:4000/trpc', + }), + ); + }); + + it('includes session cookie in headers', () => { + const config = { serverUrl: 'http://localhost:3000', sessionToken: 'secret-token' }; + + createDashboardClient(config); + + const linkOpts = vi.mocked(httpBatchLink).mock.calls[0][0] as { + headers: () => Record; + }; + const headers = linkOpts.headers(); + expect(headers).toEqual({ + Cookie: 'cascade_session=secret-token', + }); + }); + + it('returns the created client', () => { + const config = { serverUrl: 'http://localhost:3000', sessionToken: 'tok' }; + + const client = createDashboardClient(config); + + expect(client).toEqual({ mock: 'client' }); + }); +}); diff --git a/tests/unit/cli/dashboard/config.test.ts b/tests/unit/cli/dashboard/config.test.ts new file mode 100644 index 00000000..cc0612c0 --- /dev/null +++ b/tests/unit/cli/dashboard/config.test.ts @@ -0,0 +1,171 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('node:fs', () => ({ + existsSync: vi.fn(), + mkdirSync: vi.fn(), + readFileSync: vi.fn(), + writeFileSync: vi.fn(), +})); + +vi.mock('node:os', () => ({ + homedir: () => '/mock-home', +})); + +import { + clearConfig, + loadConfig, + saveConfig, +} from '../../../../src/cli/dashboard/_shared/config.js'; + +describe('config', () => { + const originalEnv = process.env; + + beforeEach(() => { + vi.clearAllMocks(); + process.env = { ...originalEnv }; + process.env.CASCADE_SERVER_URL = undefined; + process.env.CASCADE_SESSION_TOKEN = undefined; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('loadConfig', () => { + it('returns env var config when both env vars are set', () => { + process.env.CASCADE_SERVER_URL = 'http://env-server:3000'; + process.env.CASCADE_SESSION_TOKEN = 'env-token'; + + const config = loadConfig(); + + expect(config).toEqual({ + serverUrl: 'http://env-server:3000', + sessionToken: 'env-token', + }); + expect(existsSync).not.toHaveBeenCalled(); + }); + + it('returns null when config file does not exist', () => { + vi.mocked(existsSync).mockReturnValue(false); + + expect(loadConfig()).toBeNull(); + }); + + it('reads config from file when no env vars set', () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ serverUrl: 'http://localhost:3000', sessionToken: 'file-token' }), + ); + + const config = loadConfig(); + + expect(config).toEqual({ + serverUrl: 'http://localhost:3000', + sessionToken: 'file-token', + }); + expect(readFileSync).toHaveBeenCalledWith(expect.stringContaining('cli.json'), 'utf-8'); + }); + + it('returns null when file has incomplete config', () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ serverUrl: 'http://x' })); + + expect(loadConfig()).toBeNull(); + }); + + it('returns null when file contains invalid JSON', () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue('not json'); + + expect(loadConfig()).toBeNull(); + }); + + it('env var overrides file serverUrl but uses file sessionToken', () => { + process.env.CASCADE_SERVER_URL = 'http://env-override:3000'; + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ serverUrl: 'http://file:3000', sessionToken: 'file-token' }), + ); + + const config = loadConfig(); + + expect(config).toEqual({ + serverUrl: 'http://env-override:3000', + sessionToken: 'file-token', + }); + }); + + it('env var overrides file sessionToken but uses file serverUrl', () => { + process.env.CASCADE_SESSION_TOKEN = 'env-token-override'; + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue( + JSON.stringify({ serverUrl: 'http://file:3000', sessionToken: 'file-token' }), + ); + + const config = loadConfig(); + + expect(config).toEqual({ + serverUrl: 'http://file:3000', + sessionToken: 'env-token-override', + }); + }); + }); + + describe('saveConfig', () => { + it('creates config directory if it does not exist', () => { + vi.mocked(existsSync).mockReturnValue(false); + + saveConfig({ serverUrl: 'http://x', sessionToken: 'tok' }); + + expect(mkdirSync).toHaveBeenCalledWith(expect.stringContaining('.cascade'), { + recursive: true, + }); + }); + + it('does not create directory if it already exists', () => { + vi.mocked(existsSync).mockReturnValue(true); + + saveConfig({ serverUrl: 'http://x', sessionToken: 'tok' }); + + expect(mkdirSync).not.toHaveBeenCalled(); + }); + + it('writes JSON with trailing newline', () => { + vi.mocked(existsSync).mockReturnValue(true); + + saveConfig({ serverUrl: 'http://localhost:3000', sessionToken: 'abc' }); + + const written = vi.mocked(writeFileSync).mock.calls[0][1] as string; + expect(written).toContain('"serverUrl"'); + expect(written).toContain('"sessionToken"'); + expect(written.endsWith('\n')).toBe(true); + expect(JSON.parse(written)).toEqual({ + serverUrl: 'http://localhost:3000', + sessionToken: 'abc', + }); + }); + }); + + describe('clearConfig', () => { + it('writes empty object when config file exists', () => { + vi.mocked(existsSync).mockReturnValue(true); + + clearConfig(); + + expect(writeFileSync).toHaveBeenCalledWith( + expect.stringContaining('cli.json'), + '{}', + 'utf-8', + ); + }); + + it('does nothing when config file does not exist', () => { + vi.mocked(existsSync).mockReturnValue(false); + + clearConfig(); + + expect(writeFileSync).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/cli/dashboard/format.test.ts b/tests/unit/cli/dashboard/format.test.ts new file mode 100644 index 00000000..f8d2eb71 --- /dev/null +++ b/tests/unit/cli/dashboard/format.test.ts @@ -0,0 +1,233 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('chalk', () => ({ + default: { + bold: (s: string) => s, + blue: (s: string) => s, + green: (s: string) => s, + red: (s: string) => s, + yellow: (s: string) => s, + dim: (s: string) => s, + }, +})); + +import { + formatBoolean, + formatCost, + formatDate, + formatDuration, + formatStatus, + printDetail, + printTable, +} from '../../../../src/cli/dashboard/_shared/format.js'; + +describe('formatDate', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-02-16T12:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns dash for falsy input', () => { + expect(formatDate(null)).toBe('—'); + expect(formatDate(undefined)).toBe('—'); + expect(formatDate('')).toBe('—'); + }); + + it('returns "just now" for <60 seconds ago', () => { + expect(formatDate('2026-02-16T11:59:30Z')).toBe('just now'); + }); + + it('returns minutes for <1 hour ago', () => { + expect(formatDate('2026-02-16T11:30:00Z')).toBe('30m ago'); + }); + + it('returns hours for <1 day ago', () => { + expect(formatDate('2026-02-16T06:00:00Z')).toBe('6h ago'); + }); + + it('returns days for <1 week ago', () => { + expect(formatDate('2026-02-14T12:00:00Z')).toBe('2d ago'); + }); + + it('returns ISO date for >1 week ago', () => { + expect(formatDate('2026-01-01T00:00:00Z')).toBe('2026-01-01'); + }); +}); + +describe('formatDuration', () => { + it('returns dash for null/undefined', () => { + expect(formatDuration(null)).toBe('—'); + expect(formatDuration(undefined)).toBe('—'); + }); + + it('formats milliseconds', () => { + expect(formatDuration(500)).toBe('500ms'); + }); + + it('formats seconds', () => { + expect(formatDuration(5000)).toBe('5s'); + expect(formatDuration(45000)).toBe('45s'); + }); + + it('formats minutes and seconds', () => { + expect(formatDuration(83000)).toBe('1m 23s'); + expect(formatDuration(600000)).toBe('10m 0s'); + }); +}); + +describe('formatCost', () => { + it('returns dash for null/undefined', () => { + expect(formatCost(null)).toBe('—'); + expect(formatCost(undefined)).toBe('—'); + }); + + it('formats as USD with 2 decimals', () => { + expect(formatCost(0.42)).toBe('$0.42'); + expect(formatCost(1)).toBe('$1.00'); + expect(formatCost(0)).toBe('$0.00'); + }); + + it('handles string numbers', () => { + expect(formatCost('3.14')).toBe('$3.14'); + }); +}); + +describe('formatStatus', () => { + it('returns colored status for known statuses', () => { + expect(formatStatus('running')).toBe('running'); + expect(formatStatus('success')).toBe('success'); + expect(formatStatus('failed')).toBe('failed'); + expect(formatStatus('cancelled')).toBe('cancelled'); + }); + + it('returns raw string for unknown status', () => { + expect(formatStatus('pending')).toBe('pending'); + }); + + it('handles null/undefined', () => { + expect(formatStatus(null)).toBe(''); + expect(formatStatus(undefined)).toBe(''); + }); +}); + +describe('formatBoolean', () => { + it('returns yes for truthy', () => { + expect(formatBoolean(true)).toBe('yes'); + expect(formatBoolean(1)).toBe('yes'); + }); + + it('returns no for falsy', () => { + expect(formatBoolean(false)).toBe('no'); + expect(formatBoolean(0)).toBe('no'); + expect(formatBoolean(null)).toBe('no'); + }); +}); + +describe('printTable', () => { + let consoleSpy: ReturnType; + + beforeEach(() => { + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + it('prints "(no results)" for empty rows', () => { + printTable([], [{ key: 'id', header: 'ID' }]); + + expect(consoleSpy).toHaveBeenCalledWith(' (no results)'); + }); + + it('prints header and rows', () => { + printTable( + [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ], + [ + { key: 'id', header: 'ID' }, + { key: 'name', header: 'Name' }, + ], + ); + + // Header line + separator + 2 data rows = 4 calls + expect(consoleSpy).toHaveBeenCalledTimes(4); + // First call is the bold header + const headerLine = consoleSpy.mock.calls[0][0]; + expect(headerLine).toContain('ID'); + expect(headerLine).toContain('Name'); + }); + + it('applies format function to values', () => { + printTable( + [{ cost: 1.5 }], + [{ key: 'cost', header: 'Cost', format: (v) => `$${Number(v).toFixed(2)}` }], + ); + + // Header + separator + 1 row + expect(consoleSpy).toHaveBeenCalledTimes(3); + const dataLine = consoleSpy.mock.calls[2][0]; + expect(dataLine).toContain('$1.50'); + }); + + it('handles undefined values gracefully', () => { + printTable( + [{ id: 1 }], + [ + { key: 'id', header: 'ID' }, + { key: 'missing', header: 'Missing' }, + ], + ); + + expect(consoleSpy).toHaveBeenCalledTimes(3); + }); +}); + +describe('printDetail', () => { + let consoleSpy: ReturnType; + + beforeEach(() => { + consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + it('prints key-value pairs', () => { + printDetail( + { name: 'Alice', role: 'admin' }, + { + name: { label: 'Name' }, + role: { label: 'Role' }, + }, + ); + + expect(consoleSpy).toHaveBeenCalledTimes(2); + expect(consoleSpy.mock.calls[0][0]).toContain('Name'); + expect(consoleSpy.mock.calls[0][0]).toContain('Alice'); + expect(consoleSpy.mock.calls[1][0]).toContain('Role'); + expect(consoleSpy.mock.calls[1][0]).toContain('admin'); + }); + + it('shows dash for missing values', () => { + printDetail({ name: undefined }, { name: { label: 'Name' } }); + + expect(consoleSpy.mock.calls[0][0]).toContain('—'); + }); + + it('applies format function', () => { + printDetail( + { active: true }, + { active: { label: 'Active', format: (v) => (v ? 'YES' : 'NO') } }, + ); + + expect(consoleSpy.mock.calls[0][0]).toContain('YES'); + }); +});