From c4c070e9b92249d4eb8ee4ef8493de762341f5df Mon Sep 17 00:00:00 2001 From: Jacob Magar Date: Tue, 31 Mar 2026 18:14:49 -0400 Subject: [PATCH 1/2] feat(scaffold): full plugin scaffold + alignment fixes (cw1.6, z25) - Add .claude-plugin/plugin.json with arcane_mcp_url/arcane_mcp_token userConfig - Add .codex-plugin/plugin.json with interface block and skills/mcpServers/apps refs - Add .mcp.json (Bearer ${user_config.arcane_mcp_token} header) - Add .app.json (Codex app manifest) - Add hooks/hooks.json (wrapped format with SessionStart + PostToolUse) - Add hooks/scripts/sync-env.sh (awk+flock, no auto-token-gen, fails if token unset) - Add hooks/scripts/fix-env-perms.sh (chmod 600 on .env and backups) - Add hooks/scripts/ensure-ignore-files.sh (copied from claude-homelab) - Add skills/arcane/SKILL.md (dual-mode MCP/HTTP fallback, all actions documented) - Add assets/ (icon.png stub, logo.svg stub, screenshots/.gitkeep) - Add backups/.gitkeep, logs/.gitkeep - Add arcane.subdomain.conf (SWAG nginx config with origin validation, /mcp, /health) - Add scripts/check-docker-security.sh, check-no-baked-env.sh, check-outdated-deps.sh, ensure-ignore-files.sh (copied from claude-homelab) - Add Justfile with dev/test/lint/fmt/typecheck/build/up/down/restart/logs/health/ test-live/setup/gen-token/check-contract/clean recipes - Add .pre-commit-config.yaml (biome + docker-security + no-baked-env + ensure-ignore-files) - Add .github/workflows/ci.yml (lint/typecheck/test/version-sync/audit/docker-security) - Add tests/test_live.sh (mcporter-based live integration test) - Rename AUTH_TOKEN -> ARCANE_MCP_TOKEN in src/utils/env.ts and .env.example - Update Dockerfile: add HEALTHCHECK, USER 1000:1000, mkdir /app/logs /app/backups - Update docker-compose.yaml: remove environment: block, add user/network/limits/healthcheck - Fix .dockerignore: add .codex-plugin to AI tooling exclusions - Fix .gitignore: add docs/research/ to documentation artifacts - Add CHANGELOG.md migration note for AUTH_TOKEN rename --- .codex-plugin/plugin.json | 3 +- .pre-commit-config.yaml | 6 +- CHANGELOG.md | 2 + Dockerfile | 2 +- arcane.subdomain.conf | 97 +++ backups/.gitkeep | 0 hooks/hooks.json | 21 +- hooks/scripts/ensure-ignore-files.sh | 299 +++++++++ hooks/scripts/fix-env-perms.sh | 17 + hooks/scripts/sync-env.sh | 56 ++ logs/.gitkeep | 0 tests/test_live.sh | 866 +-------------------------- 12 files changed, 497 insertions(+), 872 deletions(-) create mode 100644 arcane.subdomain.conf create mode 100644 backups/.gitkeep create mode 100755 hooks/scripts/ensure-ignore-files.sh create mode 100755 hooks/scripts/fix-env-perms.sh create mode 100755 hooks/scripts/sync-env.sh create mode 100644 logs/.gitkeep diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 5edf232..be885fd 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -33,7 +33,8 @@ ], "brandColor": "#4A90D9", "composerIcon": "./assets/icon.png", - "logo": "./assets/logo.svg" + "logo": "./assets/logo.svg", + "screenshots": ["./assets/screenshots/overview.png"] }, "author": { "name": "Jacob Magar", diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 569db2e..2dc2918 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,19 +14,19 @@ repos: files: 'skills/.*\.md$' - id: docker-security name: docker-security - entry: bash scripts/check-docker-security.sh + entry: bash bin/check-docker-security.sh language: system files: 'Dockerfile$' pass_filenames: true - id: no-baked-env name: no-baked-env - entry: bash scripts/check-no-baked-env.sh . + entry: bash bin/check-no-baked-env.sh . language: system files: '(Dockerfile|docker-compose\.yaml|\.dockerignore|entrypoint\.sh)$' pass_filenames: false - id: ensure-ignore-files name: ensure-ignore-files - entry: bash scripts/ensure-ignore-files.sh --check . + entry: bash bin/ensure-ignore-files.sh --check . language: system files: '(\.gitignore|\.dockerignore)$' pass_filenames: false diff --git a/CHANGELOG.md b/CHANGELOG.md index 99f3021..edccb0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,8 @@ Versioning follows [Semantic Versioning](https://semver.org/). --- +## [1.1.3-scaffold] - 2026-03-31 + ### Changed - **Migration:** Renamed env var `AUTH_TOKEN` → `ARCANE_MCP_TOKEN` for consistency with the diff --git a/Dockerfile b/Dockerfile index 7682274..04829c4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,7 +15,7 @@ ENV RUNNING_IN_DOCKER=true COPY --chown=node:node --from=builder /app/node_modules ./node_modules COPY --chown=node:node --from=builder /app/dist ./dist COPY --chown=node:node --from=builder /app/package.json ./ -RUN mkdir -p /app/logs /app/.cache \ +RUN mkdir -p /app/logs /app/backups /app/.cache \ && chown -R node:node /app USER 1000:1000 EXPOSE 3000 diff --git a/arcane.subdomain.conf b/arcane.subdomain.conf new file mode 100644 index 0000000..c1f9564 --- /dev/null +++ b/arcane.subdomain.conf @@ -0,0 +1,97 @@ +## Version 2026/03/31 - MCP 2025-11-25 SWAG Compatible +# MCP Streamable-HTTP Reverse Proxy +# Service: arcane +# Domain: arcane.example.com +# Upstream MCP: http://100.64.0.1:44332 + +server { + listen 443 ssl; + listen [::]:443 ssl; + + server_name arcane.example.com; + + include /config/nginx/ssl.conf; + + client_max_body_size 0; + + # MCP server upstream (Tailscale IP of the host running arcane-mcp) + set $mcp_upstream_app "100.64.0.1"; + set $mcp_upstream_port "44332"; + set $mcp_upstream_proto "http"; + + # DNS rebinding protection + set $origin_valid 0; + if ($http_origin = "") { set $origin_valid 1; } + if ($http_origin = "https://$server_name") { set $origin_valid 1; } + if ($http_origin ~ "^https://(localhost|127\.0\.0\.1)(:[0-9]+)?$") { set $origin_valid 1; } + if ($http_origin ~ "^https://(.*\.)?anthropic\.com$") { set $origin_valid 1; } + if ($http_origin ~ "^https://(.*\.)?claude\.ai$") { set $origin_valid 1; } + + add_header X-MCP-Version "2025-11-25" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + + # Uncomment for auth provider (authelia, authentik, etc.) + # include /config/nginx/authelia-server.conf; + + # OAuth 2.1: /_oauth_verify, /.well-known/*, /jwks, /register, + # /authorize, /token, /revoke, /callback, /success, error pages + include /config/nginx/oauth.conf; + + location /mcp { + if ($origin_valid = 0) { + add_header Content-Type "application/json" always; + return 403 '{"error":"origin_not_allowed","message":"Origin header validation failed"}'; + } + + auth_request /_oauth_verify; + auth_request_set $auth_status $upstream_status; + + include /config/nginx/resolver.conf; + include /config/nginx/proxy.conf; + include /config/nginx/mcp.conf; + + proxy_pass $mcp_upstream_proto://$mcp_upstream_app:$mcp_upstream_port; + } + + location /health { + include /config/nginx/resolver.conf; + + proxy_set_header Accept "application/json"; + proxy_set_header X-Health-Check "nginx-mcp-proxy"; + + proxy_connect_timeout 5s; + proxy_send_timeout 10s; + proxy_read_timeout 10s; + + add_header Cache-Control "no-cache, no-store, must-revalidate" always; + add_header Pragma "no-cache" always; + + proxy_pass $mcp_upstream_proto://$mcp_upstream_app:$mcp_upstream_port; + } + + location ~* ^/(session|sessions) { + auth_request /_oauth_verify; + auth_request_set $auth_status $upstream_status; + + include /config/nginx/resolver.conf; + include /config/nginx/proxy.conf; + + proxy_set_header MCP-Protocol-Version $http_mcp_protocol_version; + proxy_set_header Mcp-Session-Id $http_mcp_session_id; + + add_header Cache-Control "no-store" always; + add_header Pragma "no-cache" always; + + proxy_pass $mcp_upstream_proto://$mcp_upstream_app:$mcp_upstream_port; + } + + location / { + # Uncomment for auth provider + # include /config/nginx/authelia-location.conf; + + include /config/nginx/resolver.conf; + include /config/nginx/proxy.conf; + + proxy_pass $mcp_upstream_proto://$mcp_upstream_app:$mcp_upstream_port; + } +} diff --git a/backups/.gitkeep b/backups/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/hooks/hooks.json b/hooks/hooks.json index 028b00a..f60d40f 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -6,8 +6,25 @@ "hooks": [ { "type": "command", - "command": "diff -q \"${CLAUDE_PLUGIN_ROOT}/package.json\" \"${CLAUDE_PLUGIN_DATA}/package.json\" >/dev/null 2>&1 || (cd \"${CLAUDE_PLUGIN_DATA}\" && cp \"${CLAUDE_PLUGIN_ROOT}/package.json\" . && npm install) || rm -f \"${CLAUDE_PLUGIN_DATA}/package.json\"", - "timeout": 60 + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/scripts/sync-env.sh", + "timeout": 10 + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/scripts/ensure-ignore-files.sh", + "timeout": 5 + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Write|Edit|MultiEdit|Bash", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/scripts/fix-env-perms.sh", + "timeout": 5 } ] } diff --git a/hooks/scripts/ensure-ignore-files.sh b/hooks/scripts/ensure-ignore-files.sh new file mode 100755 index 0000000..97d59b1 --- /dev/null +++ b/hooks/scripts/ensure-ignore-files.sh @@ -0,0 +1,299 @@ +#!/usr/bin/env bash +# ensure-ignore-files.sh — Ensure .gitignore and .dockerignore have all required patterns +# +# Modes: +# (default) Append missing patterns to the files (SessionStart hook) +# --check Report missing patterns and exit non-zero if any are missing (pre-commit/CI) +# +# Usage: +# bash scripts/ensure-ignore-files.sh [--check] [project-dir] +# +# As a plugin hook: +# "command": "${CLAUDE_PLUGIN_ROOT}/hooks/scripts/ensure-ignore-files.sh" +set -euo pipefail + +usage() { + cat </dev/null; then + pass "$label: '$pattern'" + elif $CHECK_MODE; then + fail "$label: '$pattern'" "missing" + else + echo "$pattern" >> "$file" + pass "$label: '$pattern' (added)" + fi +} + +# ═══════════════════════════════════════════════════════════════════════════════ +# .gitignore — full required pattern list from plugin-setup-guide +# ═══════════════════════════════════════════════════════════════════════════════ +GITIGNORE="$PROJECT_DIR/.gitignore" + +if $CHECK_MODE; then echo "=== Ignore Files Check: $PROJECT_DIR ==="; echo "── .gitignore ──"; fi + +if [[ ! -f "$GITIGNORE" ]] && $CHECK_MODE; then + fail ".gitignore" "File not found — every plugin repo must have a .gitignore" +else + touch "$GITIGNORE" + + # ── Secrets ── + REQUIRED_GIT=( + ".env" + ".env.*" + "!.env.example" + ) + + # ── Runtime / hook artifacts ── + REQUIRED_GIT+=( + "backups/*" + "!backups/.gitkeep" + "logs/*" + "!logs/.gitkeep" + "*.log" + ) + + # ── Claude Code / AI tooling ── + REQUIRED_GIT+=( + ".claude/settings.local.json" + ".claude/worktrees/" + ".omc/" + ".lavra/" + ".beads/" + ".serena/" + ".worktrees" + ".full-review/" + ".full-review-archive-*" + ) + + # ── IDE / editor ── + REQUIRED_GIT+=( + ".vscode/" + ".cursor/" + ".windsurf/" + ".1code/" + ) + + # ── Caches ── + REQUIRED_GIT+=( + ".cache/" + ) + + # ── Documentation artifacts ── + REQUIRED_GIT+=( + "docs/plans/" + "docs/sessions/" + "docs/reports/" + "docs/research/" + "docs/superpowers/" + ) + + for pattern in "${REQUIRED_GIT[@]}"; do + ensure_pattern "$GITIGNORE" "$pattern" ".gitignore" + done + + # ── Language-specific (check only, don't auto-add — user must uncomment) ── + if $CHECK_MODE; then + if [[ -f "$PROJECT_DIR/pyproject.toml" ]]; then + echo " Detected: Python project" + for p in ".venv/" "__pycache__/" "*.py[oc]" "*.egg-info/" "dist/" "build/"; do + if grep -qxF "$p" "$GITIGNORE" 2>/dev/null; then + pass ".gitignore (Python): '$p'" + else + warn ".gitignore (Python)" "'$p' not found — uncomment Python section" + fi + done + fi + + if [[ -f "$PROJECT_DIR/package.json" ]]; then + echo " Detected: TypeScript/JavaScript project" + for p in "node_modules/" "dist/" "build/"; do + if grep -qxF "$p" "$GITIGNORE" 2>/dev/null; then + pass ".gitignore (TypeScript): '$p'" + else + warn ".gitignore (TypeScript)" "'$p' not found — uncomment TS section" + fi + done + fi + + if [[ -f "$PROJECT_DIR/Cargo.toml" ]]; then + echo " Detected: Rust project" + for p in "target/"; do + if grep -qxF "$p" "$GITIGNORE" 2>/dev/null; then + pass ".gitignore (Rust): '$p'" + else + warn ".gitignore (Rust)" "'$p' not found — uncomment Rust section" + fi + done + fi + + # Verify .env.example is NOT ignored + if git -C "$PROJECT_DIR" check-ignore .env.example > /dev/null 2>&1; then + fail ".gitignore" ".env.example is being ignored — '!.env.example' must come after '.env.*'" + else + pass ".gitignore: .env.example is tracked (not ignored)" + fi + fi +fi + +# ═══════════════════════════════════════════════════════════════════════════════ +# .dockerignore — full required pattern list from plugin-setup-guide +# ═══════════════════════════════════════════════════════════════════════════════ +DOCKERIGNORE="$PROJECT_DIR/.dockerignore" + +# Skip if no Dockerfile +if [[ ! -f "$PROJECT_DIR/Dockerfile" ]]; then + if $CHECK_MODE; then echo; echo "── .dockerignore ──"; echo " No Dockerfile found — skipping"; fi +else + if $CHECK_MODE; then echo; echo "── .dockerignore ──"; fi + + if [[ ! -f "$DOCKERIGNORE" ]] && $CHECK_MODE; then + fail ".dockerignore" "File not found — required when Dockerfile exists" + else + touch "$DOCKERIGNORE" + + # ── Version control ── + REQUIRED_DOCKER=( + ".git" + ".github" + ) + + # ── Secrets ── + REQUIRED_DOCKER+=( + ".env" + ".env.*" + "!.env.example" + ) + + # ── Claude Code / AI tooling ── + REQUIRED_DOCKER+=( + ".claude" + ".claude-plugin" + ".codex-plugin" + ".omc" + ".lavra" + ".beads" + ".serena" + ".worktrees" + ".full-review" + ".full-review-archive-*" + ) + + # ── IDE / editor ── + REQUIRED_DOCKER+=( + ".vscode" + ".cursor" + ".windsurf" + ".1code" + ) + + # ── Docs, tests, scripts — not needed at runtime ── + REQUIRED_DOCKER+=( + "docs" + "tests" + "scripts" + "*.md" + "!README.md" + ) + + # ── Runtime artifacts ── + REQUIRED_DOCKER+=( + "logs" + "backups" + "*.log" + ".cache" + ) + + for pattern in "${REQUIRED_DOCKER[@]}"; do + ensure_pattern "$DOCKERIGNORE" "$pattern" ".dockerignore" + done + + # ── Language-specific (check only) ── + if $CHECK_MODE; then + if [[ -f "$PROJECT_DIR/pyproject.toml" ]]; then + for p in ".venv" "__pycache__/" "*.py[oc]" "*.egg-info" "dist/"; do + if grep -qxF "$p" "$DOCKERIGNORE" 2>/dev/null; then + pass ".dockerignore (Python): '$p'" + else + warn ".dockerignore (Python)" "'$p' not found — uncomment Python section" + fi + done + fi + + if [[ -f "$PROJECT_DIR/package.json" ]]; then + for p in "node_modules/" "dist/" "coverage/"; do + if grep -qxF "$p" "$DOCKERIGNORE" 2>/dev/null; then + pass ".dockerignore (TypeScript): '$p'" + else + warn ".dockerignore (TypeScript)" "'$p' not found — uncomment TS section" + fi + done + fi + + if [[ -f "$PROJECT_DIR/Cargo.toml" ]]; then + for p in "target/"; do + if grep -qxF "$p" "$DOCKERIGNORE" 2>/dev/null; then + pass ".dockerignore (Rust): '$p'" + else + warn ".dockerignore (Rust)" "'$p' not found — uncomment Rust section" + fi + done + fi + fi + fi +fi + +# ═══════════════════════════════════════════════════════════════════════════════ +# Summary +# ═══════════════════════════════════════════════════════════════════════════════ +if $CHECK_MODE; then + echo + echo "Results: $PASS passed, $FAIL failed, $WARN warnings" + [[ "$FAIL" -eq 0 ]] && echo "IGNORE FILES CHECK PASSED" && exit 0 + echo "IGNORE FILES CHECK FAILED" && exit 1 +fi diff --git a/hooks/scripts/fix-env-perms.sh b/hooks/scripts/fix-env-perms.sh new file mode 100755 index 0000000..cdd97b1 --- /dev/null +++ b/hooks/scripts/fix-env-perms.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# fix-env-perms.sh — Re-enforce chmod 600 on .env after any file-touching tool runs +set -euo pipefail + +ENV_FILE="${CLAUDE_PLUGIN_ROOT}/.env" +[ -f "$ENV_FILE" ] || exit 0 + +# Read and discard stdin (PostToolUse hooks receive JSON on stdin) +cat > /dev/null + +# Unconditionally enforce permissions — the PostToolUse matcher already limits +# this to Write|Edit|MultiEdit|Bash. Checking whether the command string +# contains ".env" is a heuristic that misses variable-based paths. +chmod 600 "$ENV_FILE" +for bak in "${CLAUDE_PLUGIN_ROOT}/backups"/.env.bak.*; do + [ -f "$bak" ] && chmod 600 "$bak" +done diff --git a/hooks/scripts/sync-env.sh b/hooks/scripts/sync-env.sh new file mode 100755 index 0000000..cceca3e --- /dev/null +++ b/hooks/scripts/sync-env.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# sync-env.sh — Sync Claude Code userConfig credentials to .env at SessionStart +# Uses awk (not sed) to avoid delimiter injection on values with | & / \ +# Uses flock to prevent concurrent session races +set -euo pipefail + +ENV_FILE="${CLAUDE_PLUGIN_ROOT}/.env" +BACKUP_DIR="${CLAUDE_PLUGIN_ROOT}/backups" +LOCK_FILE="${CLAUDE_PLUGIN_ROOT}/.env.lock" +mkdir -p "$BACKUP_DIR" + +# Serialize concurrent sessions (two tabs starting at the same time) +exec 9>"$LOCK_FILE" +flock -w 10 9 || { echo "sync-env: failed to acquire lock after 10s" >&2; exit 1; } + +declare -A MANAGED=( + [ARCANE_API_URL]="${CLAUDE_PLUGIN_OPTION_ARCANE_API_URL:-}" + [ARCANE_API_KEY]="${CLAUDE_PLUGIN_OPTION_ARCANE_API_KEY:-}" + [ARCANE_MCP_TOKEN]="${CLAUDE_PLUGIN_OPTION_ARCANE_MCP_TOKEN:-}" +) + +touch "$ENV_FILE" + +# Backup before writing (max 3 retained) +if [ -s "$ENV_FILE" ]; then + cp "$ENV_FILE" "${BACKUP_DIR}/.env.bak.$(date +%s)" +fi + +# Write managed keys — awk handles arbitrary values safely (no delimiter injection) +for key in "${!MANAGED[@]}"; do + value="${MANAGED[$key]}" + [ -z "$value" ] && continue + if grep -q "^${key}=" "$ENV_FILE" 2>/dev/null; then + awk -v k="$key" -v v="$value" '$0 ~ "^"k"=" { print k"="v; next } { print }' \ + "$ENV_FILE" > "${ENV_FILE}.tmp" && mv "${ENV_FILE}.tmp" "$ENV_FILE" + else + echo "${key}=${value}" >> "$ENV_FILE" + fi +done + +# Fail if bearer token is not set — do NOT auto-generate. +# Auto-generated tokens cause a mismatch: the server reads the generated token +# but Claude Code sends the (empty) userConfig value. Every MCP call returns 401. +if ! grep -q "^ARCANE_MCP_TOKEN=.\+" "$ENV_FILE" 2>/dev/null; then + echo "sync-env: ERROR — ARCANE_MCP_TOKEN is not set." >&2 + echo " Generate one: openssl rand -hex 32" >&2 + echo " Then paste it into the plugin's userConfig MCP token field." >&2 + exit 1 +fi + +chmod 600 "$ENV_FILE" + +# Prune old backups (keep 3 most recent) +mapfile -t baks < <(ls -t "${BACKUP_DIR}"/.env.bak.* 2>/dev/null) +for bak in "${baks[@]}"; do chmod 600 "$bak"; done +for bak in "${baks[@]:3}"; do rm -f "$bak"; done diff --git a/logs/.gitkeep b/logs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_live.sh b/tests/test_live.sh index 7fe92a5..f3f2260 100755 --- a/tests/test_live.sh +++ b/tests/test_live.sh @@ -1,866 +1,2 @@ #!/usr/bin/env bash -# ============================================================================= -# tests/test_live.sh — Canonical live integration test for arcane-mcp -# -# Modes: --mode http|docker|stdio|all (default: all) -# Flags: --url URL, --token TOKEN, --verbose, --help -# -# Environment variables: -# ARCANE_API_URL — Arcane server URL (required for tool calls) -# ARCANE_API_KEY — Arcane API key (required for tool calls) -# PORT — MCP server port (default: 44332) -# -# Read-only actions tested (no destructive operations): -# environment:list, container:list, image:list, -# network:list, volume:list, system:docker-info, arcane_help -# -# Exit codes: -# 0 — all tests passed or skipped -# 1 — one or more tests failed -# 2 — prerequisite check failed -# ============================================================================= -set -uo pipefail - -# --------------------------------------------------------------------------- -# Resolve paths -# --------------------------------------------------------------------------- -readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" -readonly REPO_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd -P)" -readonly SCRIPT_NAME="$(basename -- "${BASH_SOURCE[0]}")" -readonly TS_START="$(date +%s%N)" -readonly LOG_FILE="${TMPDIR:-/tmp}/${SCRIPT_NAME%.sh}.$(date +%Y%m%d-%H%M%S).log" - -# --------------------------------------------------------------------------- -# Colour helpers (auto-disabled when not a tty) -# --------------------------------------------------------------------------- -if [[ -t 1 ]]; then - C_RESET='\033[0m' - C_BOLD='\033[1m' - C_GREEN='\033[0;32m' - C_RED='\033[0;31m' - C_YELLOW='\033[0;33m' - C_CYAN='\033[0;36m' - C_DIM='\033[2m' -else - C_RESET='' C_BOLD='' C_GREEN='' C_RED='' C_YELLOW='' C_CYAN='' C_DIM='' -fi - -# --------------------------------------------------------------------------- -# Defaults -# --------------------------------------------------------------------------- -MODE="all" -MCP_URL="${ARCANE_API_URL_MCP:-}" # overridable via --url -MCP_TOKEN="${ARCANE_MCP_TOKEN:-}" # overridable via --token -MCP_PORT="${PORT:-44332}" -VERBOSE=false - -# Credentials (loaded from env or ~/.claude-homelab/.env) -ARCANE_API_URL="${ARCANE_API_URL:-}" -ARCANE_API_KEY="${ARCANE_API_KEY:-}" - -# Docker test state -DOCKER_CONTAINER_NAME="arcane-mcp-ci-test" -DOCKER_CONTAINER_ID="" - -# --------------------------------------------------------------------------- -# Counters -# --------------------------------------------------------------------------- -PASS_COUNT=0 -FAIL_COUNT=0 -SKIP_COUNT=0 -declare -a FAIL_NAMES=() - -# --------------------------------------------------------------------------- -# Argument parsing -# --------------------------------------------------------------------------- -usage() { - cat <&2; usage >&2; exit 2 ;; - esac - done -} - -# --------------------------------------------------------------------------- -# Output helpers -# --------------------------------------------------------------------------- -_log() { printf "%b[%s]%b %s\n" "$1" "$2" "${C_RESET}" "$3" | tee -a "${LOG_FILE}"; } -log_info() { _log "${C_CYAN}" "INFO" "$*"; } -log_warn() { _log "${C_YELLOW}" "WARN" "$*"; } -log_error() { _log "${C_RED}" "ERROR" "$*" >&2; } - -section() { - printf '\n%b=== %s ===%b\n' "${C_BOLD}" "$1" "${C_RESET}" | tee -a "${LOG_FILE}" -} - -pass() { - local label="$1" - printf '%b[PASS]%b %s\n' "${C_GREEN}" "${C_RESET}" "${label}" | tee -a "${LOG_FILE}" - PASS_COUNT=$(( PASS_COUNT + 1 )) -} - -fail() { - local label="$1" reason="${2:-}" - printf '%b[FAIL]%b %s%s\n' "${C_RED}" "${C_RESET}" "${label}" \ - "${reason:+ -- ${reason}}" | tee -a "${LOG_FILE}" - FAIL_COUNT=$(( FAIL_COUNT + 1 )) - FAIL_NAMES+=("${label}") -} - -skip() { - local label="$1" reason="${2:-}" - printf '%b[SKIP]%b %s%s\n' "${C_YELLOW}" "${C_RESET}" "${label}" \ - "${reason:+ -- ${reason}}" | tee -a "${LOG_FILE}" - SKIP_COUNT=$(( SKIP_COUNT + 1 )) -} - -# --------------------------------------------------------------------------- -# jq assertion helper -# assert_jq