diff --git a/.github/workflows/connector-publish.yaml b/.github/workflows/connector-publish.yaml new file mode 100644 index 00000000..69d7f034 --- /dev/null +++ b/.github/workflows/connector-publish.yaml @@ -0,0 +1,83 @@ +# Reusable workflow for publishing connectors to the ConductorOne registry. +# +# Usage in your connector repo: +# +# name: Publish Connector +# on: +# release: +# types: [published] +# jobs: +# publish: +# uses: ConductorOne/cone/.github/workflows/connector-publish.yaml@main +# with: +# connector-name: my-service +# secrets: +# registry-token: ${{ secrets.C1_REGISTRY_TOKEN }} + +name: Connector Publish + +on: + workflow_call: + inputs: + connector-name: + description: 'Connector name (e.g., "github", "okta")' + required: true + type: string + registry-url: + description: 'Registry URL (defaults to production)' + required: false + type: string + default: 'https://registry.conductorone.com' + config-path: + description: 'Path to connector config file' + required: false + type: string + default: '.baton.yaml' + secrets: + registry-token: + description: 'ConductorOne registry authentication token' + required: true + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.23.x' + + - name: Install cone CLI + run: | + go install github.com/conductorone/cone@latest + + - name: Build connector + run: | + go build -o baton-${{ inputs.connector-name }} . + + - name: Extract version from tag + id: version + run: | + VERSION=${GITHUB_REF#refs/tags/} + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Publish to registry + env: + C1_REGISTRY_TOKEN: ${{ secrets.registry-token }} + C1_REGISTRY_URL: ${{ inputs.registry-url }} + run: | + cone connector publish \ + --name "${{ inputs.connector-name }}" \ + --version "${{ steps.version.outputs.version }}" \ + --binary "baton-${{ inputs.connector-name }}" \ + --config "${{ inputs.config-path }}" + + - name: Verify publication + env: + C1_REGISTRY_TOKEN: ${{ secrets.registry-token }} + C1_REGISTRY_URL: ${{ inputs.registry-url }} + run: | + cone registry info "${{ inputs.connector-name }}" --version "${{ steps.version.outputs.version }}" diff --git a/DEMO.md b/DEMO.md new file mode 100644 index 00000000..b58d1c90 --- /dev/null +++ b/DEMO.md @@ -0,0 +1,612 @@ +# Cone Registry CLI Demo + +Demonstrate the `cone registry` commands for browsing, downloading, and publishing connectors. + +--- + +## Connector Run Modes + +ConductorOne connectors can run in different deployment modes: + +| Mode | Description | Use Case | +|------|-------------|----------| +| **Managed (Lambda)** | Hosted by ConductorOne in AWS Lambda | Default for cloud-hosted connectors; zero infrastructure to manage | +| **Self-Hosted (Local)** | Downloaded binary running in your infrastructure | Air-gapped environments, custom networks, on-prem systems | +| **Vendored** | Built into ConductorOne platform | Legacy connectors, special integrations | + +### When You Download a Connector + +Using `cone registry download` implies **self-hosted mode**: + +- You download the connector binary to your infrastructure +- You run it locally (or in your cloud, Kubernetes, etc.) +- You configure it to sync back to ConductorOne +- You manage updates, scaling, and availability + +This is different from **managed connectors** which: +- Run automatically in ConductorOne's infrastructure +- Are configured entirely through the UI/API +- Update automatically when new versions are published +- Require no infrastructure management + +### Choosing a Run Mode + +| Scenario | Recommended Mode | +|----------|------------------| +| Standard SaaS integrations (Okta, Google, etc.) | Managed | +| On-premises systems (Active Directory, databases) | Self-Hosted | +| Air-gapped or regulated environments | Self-Hosted | +| Custom/internal applications | Self-Hosted | +| Testing connector changes | Self-Hosted | + +--- + +## Assumptions & Prerequisites + +### For Production Use + +| Requirement | Description | +|-------------|-------------| +| ConductorOne Tenant | Active tenant at `your-tenant.conductor.one` | +| Okta SSO (Optional) | If using Okta for SSO, configure OIDC application | +| Publisher Role | User must have publisher scope assigned in ConductorOne | +| Admin Role | Admin commands require admin scope in ConductorOne | + +### Authentication Flow + +The `cone login` command uses OAuth 2.0 with PKCE: + +1. Opens browser to ConductorOne login page +2. User authenticates (directly or via SSO like Okta) +3. ConductorOne issues JWT with user's scopes +4. Token stored in `~/.conductorone/config.yaml` +5. Registry validates JWT against ConductorOne's JWKS endpoint + +**JWT Claims Used by Registry:** + +| Claim | Purpose | +|-------|---------| +| `iss` | Must match ConductorOne tenant URL | +| `aud` | Must match registry's configured audience | +| `sub` | User identifier for audit logging | +| `org` | Organization for resource scoping | +| `c1scp` | ConductorOne scope IDs (matched against publisher/admin roles) | + +### ConductorOne Role Configuration + +To grant registry access, assign these scopes in ConductorOne: + +| Permission Level | Required Scope | +|------------------|----------------| +| Publisher | Scope ID configured in registry's `roles.publisher` | +| Admin | Scope ID configured in registry's `roles.admin` | + +### For Local Development + +| Requirement | Description | +|-------------|-------------| +| Docker | For LocalStack (DynamoDB + S3) | +| Go 1.22+ | For building cone and registry | +| cosign (Optional) | For signature verification | + +--- + +## Part 1: Browse & Download (No Auth Required) + +These commands work without authentication against the public registry. + +### List All Connectors + +```bash +# List all available connectors (currently 51 from dist.conductorone.com) +cone registry list + +# Output as JSON for scripting +cone registry list --output json +``` + +> **Note**: The description overlay contains 167 entries (for future connectors), but dist.conductorone.com currently publishes 51 connectors. 46 of these have matching descriptions. + +### Show Connector Details + +```bash +# Show connector metadata (includes description from overlay) +cone registry show ConductorOne/baton-okta + +# Show a specific version +cone registry show ConductorOne/baton-okta v0.1.0 + +# Show available platforms for a version +cone registry show ConductorOne/baton-okta v0.1.0 --platforms +``` + +### List Versions + +```bash +# List all versions of a connector +cone registry versions ConductorOne/baton-okta +``` + +### Download a Connector + +```bash +# Download stable version for your platform +cone registry download ConductorOne/baton-okta + +# Download specific version +cone registry download ConductorOne/baton-okta v0.1.0 + +# Download for a different platform +cone registry download ConductorOne/baton-okta --platform linux-amd64 + +# Download to specific directory +cone registry download ConductorOne/baton-okta --output ./bin/ + +# Skip verification (not recommended) +cone registry download ConductorOne/baton-okta --skip-verify +``` + +--- + +## Part 2: Authentication + +Publisher and admin commands require ConductorOne authentication. + +### Login to ConductorOne + +```bash +# Login (opens browser for OAuth) +cone login your-tenant.conductor.one + +# Verify you're logged in +cone whoami +``` + +### Using Multiple Profiles + +```bash +# Login with named profiles +cone login prod-tenant.conductor.one --profile prod +cone login dev-tenant.conductor.one --profile dev + +# Use a specific profile +cone registry status --profile prod +``` + +--- + +## Part 3: Signing Keys (Publisher) + +Manage cryptographic keys for signing connector releases. + +### Generate a Key Pair + +```bash +# Generate ECDSA P-256 key pair (cosign-compatible) +cone registry keys generate release-2024 + +# Generates: +# release-2024.key (private - keep secret!) +# release-2024.pub (public - register with registry) +``` + +### Register Your Public Key + +```bash +# Register the public key with your organization +cone registry keys add ConductorOne \ + --name "Release Signing Key 2024" \ + --type cosign \ + --key-file release-2024.pub +``` + +### List Your Organization's Keys + +```bash +cone registry keys list ConductorOne +``` + +### Show Key Details + +```bash +cone registry keys show ConductorOne +``` + +--- + +## Part 4: Publishing (Publisher) + +Publish new connector versions to the registry. + +### Build Your Binaries + +```bash +# Build for multiple platforms +GOOS=linux GOARCH=amd64 go build -o dist/baton-myapp-linux-amd64 . +GOOS=linux GOARCH=arm64 go build -o dist/baton-myapp-linux-arm64 . +GOOS=darwin GOARCH=amd64 go build -o dist/baton-myapp-darwin-amd64 . +GOOS=darwin GOARCH=arm64 go build -o dist/baton-myapp-darwin-arm64 . +GOOS=windows GOARCH=amd64 go build -o dist/baton-myapp-windows-amd64.exe . +``` + +### Sign Your Binaries (Optional but Recommended) + +```bash +# Sign each binary with cosign +for binary in dist/baton-myapp-*; do + cosign sign-blob --key release-2024.key \ + --output-signature "${binary}.sig" \ + "$binary" +done +``` + +### Preview What Will Be Published + +```bash +# Dry run - shows what would be uploaded +cone registry publish MyOrg/baton-myapp v1.0.0 \ + --binary-dir ./dist/ \ + --dry-run +``` + +### Publish + +```bash +# Publish with metadata +cone registry publish MyOrg/baton-myapp v1.0.0 \ + --binary-dir ./dist/ \ + --description "Initial release" \ + --changelog "- User sync\n- Group sync\n- Entitlement sync" \ + --license "Apache-2.0" +``` + +### Check Publication Status + +```bash +# View all your published versions +cone registry status + +# Filter by connector +cone registry status --connector MyOrg/baton-myapp + +# Filter by state +cone registry status --state VALIDATING +cone registry status --state PUBLISHED +cone registry status --state FAILED +``` + +--- + +## Part 5: Admin Commands + +These require admin role permissions. + +### Set Stable Version + +```bash +# Mark a version as the stable/recommended version +cone registry set-stable MyOrg/baton-myapp v1.0.0 +``` + +### Yank a Version + +```bash +# Withdraw a version (e.g., security issue) +cone registry yank MyOrg/baton-myapp v0.9.0 --reason "Security vulnerability CVE-2024-XXXX" +``` + +### Deprecate a Connector + +```bash +# Mark connector as deprecated +cone registry deprecate MyOrg/baton-legacy --reason "Replaced by baton-myapp" +``` + +--- + +## Part 6: Data Sync (Admin) + +Compare registry against dist.conductorone.com and sync changes. + +### Check Sync Status (Diff) + +```bash +# Compare registry (DynamoDB) against dist.conductorone.com +cone registry diff + +# Example output: +# Registry vs https://dist.conductorone.com +# Generated: 2026-01-07 15:30:05 +# +# Summary: +# Connectors: +1 added, -2 removed, ~49 modified +# Versions: +15 added, -0 removed +# +# Added Connectors (in registry, not in dist): +# + TestOrg/baton-test +# +# Removed Connectors (in dist, not in registry): +# - ConductorOne/baton-old +# +# Modified Connectors: +# ~ ConductorOne/baton-okta +# stable: v0.4.3 → v0.4.4 +# versions added: v0.4.4 +``` + +```bash +# Output as JSON for scripting +cone registry diff --output json + +# Compare against staging dist +cone registry diff --base-url https://staging-dist.conductorone.com +``` + +### Export to Dist (Push) + +```bash +# Preview what would be exported (dry run) +cone registry export --dry-run + +# Example output: +# Would export all connectors: +# Connectors: 50 +# Releases: 136 +# Files: 187 +# +# (dry run - no files written) +``` + +```bash +# Export all connectors to S3 (dist layout) +cone registry export + +# Export specific connectors only +cone registry export ConductorOne/baton-okta ConductorOne/baton-aws + +# Output as JSON +cone registry export --output json +``` + +### Sync Workflow + +The `sync` command provides convenient aliases: + +```bash +# Check what would change (alias for 'registry diff') +cone registry sync status + +# Push changes to dist (alias for 'registry export') +cone registry sync push --dry-run +cone registry sync push + +# Push specific connectors +cone registry sync push ConductorOne/baton-okta +``` + +### Typical Sync Workflow + +```bash +# 1. Check current differences +cone registry diff + +# 2. Review what will be exported +cone registry export --dry-run + +# 3. Export to dist +cone registry export + +# 4. Verify sync completed +cone registry diff +# Should show: "No differences found - registry matches dist." +``` + +--- + +## Part 7: JSON Output & Scripting + +All commands support JSON output for automation. + +```bash +# List as JSON +cone registry list --output json + +# Pretty-printed JSON +cone registry show ConductorOne/baton-okta --output json-pretty + +# Use with jq for filtering +cone registry list --output json | jq '.[] | select(.stableVersion != "")' + +# Get download URL programmatically +cone registry show ConductorOne/baton-okta v0.1.0 --output json | jq -r '.downloadUrl' +``` + +--- + +## Local Development Setup + +For testing against a local registry server with real connector data. + +### Start Local Infrastructure + +```bash +cd /path/to/connector-registry + +# Start LocalStack (DynamoDB + S3) +docker compose up -d localstack + +# Wait for LocalStack to be ready +until curl -s http://localhost:4566/_localstack/health | grep -q '"dynamodb": "running"'; do + sleep 1 +done +``` + +### Import Connector Data + +There are two ways to populate the registry with connector data: + +#### Option A: Live Import (requires network) + +```bash +# Fetch directly from dist.conductorone.com +make import + +# This fetches: +# - ~51 connectors from the live catalog +# - ~147 release manifests with platform/checksum info +# - Applies description overlay (167 connector descriptions) +``` + +#### Option B: Snapshot Import (offline capable) + +```bash +# First, download a snapshot (do this once while online) +make snapshot-download +# Saves to data/catalog_snapshot.json (~900KB) + +# Later, load from snapshot (works offline) +make snapshot-load +``` + +The snapshot approach is useful for: +- Air-gapped environments +- Reproducible demo data +- Faster local development (no network fetches) + +### Description Overlay + +The import applies rich descriptions from `pkg/importer/data/description_overlay.json`: + +- **baton-okta**: "Syncs users, groups, roles, applications, and custom roles from Okta..." +- **baton-aws**: "Syncs IAM users, groups, roles, and accounts with optional AWS Identity Center..." +- **baton-azure**: "Syncs Entra ID users, groups, roles, resource groups, tenants..." + +The overlay contains descriptions for 167 connectors (more than currently published). + +### Start the Registry Server + +```bash +# Basic local server +make run-local + +# Or with dev config (for testing auth) +AWS_ACCESS_KEY_ID=test \ +AWS_SECRET_ACCESS_KEY=test \ +AWS_REGION=us-east-1 \ +AWS_ENDPOINT=http://localhost:4566 \ +DYNAMODB_TABLE=connector-registry \ +S3_BUCKET=connector-registry-binaries \ +./registry-local serve --config=config.dev.json --port=8080 +``` + +### Test Against Local Server + +```bash +# All commands accept --registry-url flag +cone registry list --registry-url http://localhost:8080 + +# Should show 51 connectors with descriptions +cone registry list --registry-url http://localhost:8080 | wc -l +# Output: 52 (51 connectors + header) +``` + +### Test Authenticated Endpoints Locally + +The `config.dev.json` enables `skip_signature_verification` for testing without real JWTs: + +```bash +# Publisher test token (c1scp: ["publisher-scope"]) +export PUBLISHER_TOKEN="eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ0ZXN0LXVzZXIiLCJpc3MiOiJ0ZXN0LWlzc3VlciIsImF1ZCI6WyJjb25uZWN0b3ItcmVnaXN0cnkiXSwiZXhwIjo0MTAyNDQ0ODAwLCJpYXQiOjE3MDQwNjcyMDAsIm9yZyI6IlRlc3RPcmciLCJyb2xlcyI6WyJwdWJsaXNoZXIiXSwiYzFzY3AiOlsicHVibGlzaGVyLXNjb3BlIl0sImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSJ9." + +# Admin test token (c1scp: ["admin-scope"]) +export ADMIN_TOKEN="eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ0ZXN0LWFkbWluIiwiaXNzIjoidGVzdC1pc3N1ZXIiLCJhdWQiOlsiY29ubmVjdG9yLXJlZ2lzdHJ5Il0sImV4cCI6NDEwMjQ0NDgwMCwiaWF0IjoxNzA0MDY3MjAwLCJjMXNjcCI6WyJhZG1pbi1zY29wZSJdLCJlbWFpbCI6ImFkbWluQHRlc3QuY29tIn0." + +# Test publisher commands +cone registry keys list TestOrg \ + --registry-url http://localhost:8080 \ + --registry-token "$PUBLISHER_TOKEN" + +cone registry status \ + --registry-url http://localhost:8080 \ + --registry-token "$PUBLISHER_TOKEN" + +# Test admin commands (diff/export) +cone registry diff \ + --registry-url http://localhost:8080 \ + --registry-token "$ADMIN_TOKEN" + +cone registry export --dry-run \ + --registry-url http://localhost:8080 \ + --registry-token "$ADMIN_TOKEN" + +# Export specific connector +cone registry export ConductorOne/baton-okta \ + --registry-url http://localhost:8080 \ + --registry-token "$ADMIN_TOKEN" \ + --dry-run +``` + +--- + +## Command Reference + +| Command | Auth | Description | +|---------|------|-------------| +| `registry list` | No | List all connectors | +| `registry show ` | No | Show connector details | +| `registry show ` | No | Show version details | +| `registry versions ` | No | List all versions | +| `registry download ` | No | Download binary | +| `registry keys generate ` | No | Generate key pair locally | +| `registry keys list ` | Publisher | List org's signing keys | +| `registry keys add ` | Publisher | Register a signing key | +| `registry keys show ` | Publisher | Show key details | +| `registry status` | Publisher | Show your published versions | +| `registry publish ` | Publisher | Publish a new version | +| `registry set-stable ` | Admin | Set stable version | +| `registry yank ` | Admin | Yank a version | +| `registry deprecate ` | Admin | Deprecate connector | +| `registry diff` | Admin | Compare registry vs dist | +| `registry export [connectors...]` | Admin | Export to dist layout (S3) | +| `registry sync status` | Admin | Alias for `diff` | +| `registry sync push [connectors...]` | Admin | Alias for `export` | + +--- + +## Troubleshooting + +### "authentication required" + +```bash +cone login your-tenant.conductor.one +cone whoami # verify +``` + +### "no stable version available" + +Specify version explicitly: +```bash +cone registry download ConductorOne/baton-okta v0.1.0 +``` + +### "checksum verification failed" + +Try again or skip verification: +```bash +cone registry download ConductorOne/baton-okta --skip-verify +``` + +### "connector not found" + +Check spelling and org/name format: +```bash +cone registry list | grep -i okta +``` + +### "permission denied" + +Verify your ConductorOne account has the required scope: +```bash +# Check your token claims +cone whoami --output json | jq '.scopes' +``` diff --git a/Makefile b/Makefile index 455f6127..1dde1d5f 100644 --- a/Makefile +++ b/Makefile @@ -23,6 +23,10 @@ add-dep: go mod tidy -v go mod vendor +.PHONY: test +test: + go test ./... + .PHONY: lint lint: golangci-lint run diff --git a/cmd/cone/connector.go b/cmd/cone/connector.go new file mode 100644 index 00000000..24adcbe0 --- /dev/null +++ b/cmd/cone/connector.go @@ -0,0 +1,31 @@ +package main + +import ( + "github.com/spf13/cobra" +) + +// connectorCmd returns the root command for connector operations. +// Subcommands: init, dev, build +func connectorCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "connector", + Short: "Manage ConductorOne connectors", + Long: `Commands for developing, building, and managing ConductorOne connectors. + +The connector subcommands help you: + - Initialize new connector projects + - Run a local development server with hot reload + - Build connector binaries for deployment + - Publish connectors to the ConductorOne registry`, + } + + cmd.AddCommand(connectorBuildCmd()) + cmd.AddCommand(connectorInitCmd()) + cmd.AddCommand(connectorDevCmd()) + cmd.AddCommand(connectorPublishCmd()) + cmd.AddCommand(connectorValidateConfigCmd()) + cmd.AddCommand(connectorConsentCmd()) + cmd.AddCommand(connectorAnalyzeCmd()) + + return cmd +} diff --git a/cmd/cone/connector_analyze.go b/cmd/cone/connector_analyze.go new file mode 100644 index 00000000..8c61e250 --- /dev/null +++ b/cmd/cone/connector_analyze.go @@ -0,0 +1,243 @@ +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + c1client "github.com/conductorone/cone/pkg/client" + "github.com/conductorone/cone/pkg/consent" + "github.com/conductorone/cone/pkg/mcpclient" + "github.com/pterm/pterm" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +func connectorAnalyzeCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "analyze [path]", + Short: "Analyze a connector with AI assistance", + Long: `Analyze a connector using ConductorOne's AI copilot. + +The AI will review your connector code and suggest improvements for: + - Resource model completeness (users, groups, entitlements, grants) + - SDK usage patterns and best practices + - Error handling and edge cases + - Performance and efficiency + +This command requires consent for AI-assisted analysis. +Grant consent with: cone connector consent --agree + +Examples: + cone connector analyze # Analyze current directory + cone connector analyze ./my-connector # Analyze specific path + cone connector analyze --offline # Run offline checks only + cone connector analyze --dry-run # Preview without applying changes`, + RunE: runConnectorAnalyze, + } + + cmd.Flags().Bool("offline", false, "Run offline analysis only (no AI)") + cmd.Flags().Bool("dry-run", false, "Preview changes without applying them") + cmd.Flags().String("mode", "interactive", "Analysis mode: interactive or batch") + cmd.Flags().String("server", "", "Override MCP server URL (for testing)") + + return cmd +} + +func runConnectorAnalyze(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + offline, _ := cmd.Flags().GetBool("offline") + dryRun, _ := cmd.Flags().GetBool("dry-run") + mode, _ := cmd.Flags().GetString("mode") + serverOverride, _ := cmd.Flags().GetString("server") + + // Determine connector path + connectorPath := "." + if len(args) > 0 { + connectorPath = args[0] + } + + // Resolve to absolute path + absPath, err := filepath.Abs(connectorPath) + if err != nil { + return fmt.Errorf("invalid path: %w", err) + } + + // Verify path exists and is a directory + info, err := os.Stat(absPath) + if err != nil { + return fmt.Errorf("path not found: %w", err) + } + if !info.IsDir() { + return fmt.Errorf("path must be a directory: %s", absPath) + } + + // Check consent + if !offline && !consent.HasValidConsent() { + pterm.Warning.Println("AI-assisted analysis requires consent.") + fmt.Println() + fmt.Println("To enable AI analysis, run:") + fmt.Println(" cone connector consent --agree") + fmt.Println() + fmt.Println("Running offline analysis instead...") + fmt.Println() + offline = true + } + + if offline { + return runOfflineAnalysis(ctx, absPath) + } + + return runOnlineAnalysis(ctx, absPath, mode, dryRun, serverOverride) +} + +// runOfflineAnalysis runs basic checks without connecting to C1. +func runOfflineAnalysis(ctx context.Context, connectorPath string) error { + spinner, _ := pterm.DefaultSpinner.Start("Running offline analysis...") + + // Check for connector configuration files + checks := []struct { + name string + files []string + passed bool + }{ + {"Configuration file", []string{"connector.yaml", ".baton.yaml", "config.yaml"}, false}, + {"Go module", []string{"go.mod"}, false}, + {"Main package", []string{"main.go", "cmd/baton-*/main.go"}, false}, + } + + for i, check := range checks { + for _, file := range check.files { + matches, _ := filepath.Glob(filepath.Join(connectorPath, file)) + if len(matches) > 0 { + checks[i].passed = true + break + } + } + } + + spinner.Success("Offline analysis complete") + fmt.Println() + + // Display results + fmt.Println("Checks:") + allPassed := true + for _, check := range checks { + if check.passed { + fmt.Printf(" [PASS] %s\n", check.name) + } else { + fmt.Printf(" [FAIL] %s\n", check.name) + allPassed = false + } + } + + fmt.Println() + if !allPassed { + fmt.Println("Some checks failed. For full AI analysis, run:") + fmt.Println(" cone connector consent --agree") + fmt.Println(" cone connector analyze") + } else { + fmt.Println("Basic checks passed. For deeper AI analysis, run:") + fmt.Println(" cone connector consent --agree") + fmt.Println(" cone connector analyze") + } + + return nil +} + +// runOnlineAnalysis connects to C1 for AI-assisted analysis. +func runOnlineAnalysis(ctx context.Context, connectorPath, mode string, dryRun bool, serverOverride string) error { + // Get server URL + serverURL := serverOverride + if serverURL == "" { + // Use configured tenant + v, err := getSubViperForProfile(nil) + if err == nil { + tenant := v.GetString("tenant") + if tenant != "" { + serverURL = fmt.Sprintf("https://%s.conductorone.com/api/v1alpha/mcp/cone", tenant) + } + } + if serverURL == "" { + serverURL = viper.GetString("mcp-server") + } + if serverURL == "" { + return fmt.Errorf("no MCP server configured. Use --server or run 'cone login' first") + } + } + + // Get auth token using cone's OAuth credential flow (same as other commands) + v, err := getSubViperForProfile(nil) + if err != nil { + return fmt.Errorf("failed to get config: %w", err) + } + + clientId, clientSecret, err := getCredentials(v) + if err != nil { + return fmt.Errorf("no credentials available. Run 'cone login' first: %w", err) + } + + tokenSrc, _, _, err := c1client.NewC1TokenSource(ctx, clientId, clientSecret, v.GetString("api-endpoint"), v.GetBool("debug")) + if err != nil { + return fmt.Errorf("failed to create token source: %w", err) + } + + token, err := tokenSrc.Token() + if err != nil { + return fmt.Errorf("failed to get auth token: %w", err) + } + authToken := token.AccessToken + + // Create tool handler + toolHandler := mcpclient.NewToolHandler(connectorPath) + toolHandler.DryRun = dryRun + + // Create client + client := mcpclient.NewClient(serverURL, authToken, toolHandler) + + // Run analysis with timeout + ctx, cancel := context.WithTimeout(ctx, 10*time.Minute) + defer cancel() + + spinner, _ := pterm.DefaultSpinner.Start("Connecting to C1...") + + if err := client.Connect(ctx); err != nil { + spinner.Fail("Connection failed") + return fmt.Errorf("failed to connect: %w", err) + } + defer client.Close() + + spinner.Success("Connected") + + // Run analysis + fmt.Println() + pterm.Info.Printf("Analyzing connector at: %s\n", connectorPath) + if dryRun { + pterm.Warning.Println("Dry run mode - no changes will be applied") + } + fmt.Println() + + result, err := client.Analyze(ctx, connectorPath, mode) + if err != nil { + return fmt.Errorf("analysis failed: %w", err) + } + + // Display results + fmt.Println() + fmt.Println("Analysis Complete") + fmt.Println("=================") + fmt.Printf("Status: %s\n", result.Status) + if result.Message != "" { + fmt.Printf("Message: %s\n", result.Message) + } + if result.FilesScanned > 0 { + fmt.Printf("Files scanned: %d\n", result.FilesScanned) + } + if result.IssuesFound > 0 { + fmt.Printf("Issues found: %d\n", result.IssuesFound) + } + + return nil +} diff --git a/cmd/cone/connector_build.go b/cmd/cone/connector_build.go new file mode 100644 index 00000000..ef43b403 --- /dev/null +++ b/cmd/cone/connector_build.go @@ -0,0 +1,90 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + + "github.com/spf13/cobra" +) + +// connectorBuildCmd returns the command for building connector binaries. +func connectorBuildCmd() *cobra.Command { + var outputPath string + var targetOS string + var targetArch string + + cmd := &cobra.Command{ + Use: "build [path]", + Short: "Build a connector binary", + Long: `Build a connector binary from the specified path. + +If no path is provided, builds from the current directory. + +Examples: + cone connector build + cone connector build ./my-connector + cone connector build -o ./dist/connector + cone connector build --os linux --arch amd64`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + buildPath := "." + if len(args) > 0 { + buildPath = args[0] + } + + // Resolve absolute path + absPath, err := filepath.Abs(buildPath) + if err != nil { + return fmt.Errorf("failed to resolve path: %w", err) + } + + // Check if directory exists and contains go.mod + goModPath := filepath.Join(absPath, "go.mod") + if _, err := os.Stat(goModPath); os.IsNotExist(err) { + return fmt.Errorf("no go.mod found in %s - is this a Go project?", absPath) + } + + // Determine output path + if outputPath == "" { + outputPath = filepath.Join(absPath, "connector") + if targetOS == "windows" || runtime.GOOS == "windows" { + outputPath += ".exe" + } + } + + // Set up build environment + buildEnv := os.Environ() + if targetOS != "" { + buildEnv = append(buildEnv, "GOOS="+targetOS) + } + if targetArch != "" { + buildEnv = append(buildEnv, "GOARCH="+targetArch) + } + + // Build the connector + // Template creates main.go at root, so build from "." + buildCmd := exec.Command("go", "build", "-o", outputPath, ".") + buildCmd.Dir = absPath + buildCmd.Env = buildEnv + buildCmd.Stdout = os.Stdout + buildCmd.Stderr = os.Stderr + + fmt.Printf("Building connector in %s...\n", absPath) + if err := buildCmd.Run(); err != nil { + return fmt.Errorf("build failed: %w", err) + } + + fmt.Printf("Built: %s\n", outputPath) + return nil + }, + } + + cmd.Flags().StringVarP(&outputPath, "output", "o", "", "Output path for the binary") + cmd.Flags().StringVar(&targetOS, "os", "", "Target operating system (e.g., linux, darwin, windows)") + cmd.Flags().StringVar(&targetArch, "arch", "", "Target architecture (e.g., amd64, arm64)") + + return cmd +} diff --git a/cmd/cone/connector_consent.go b/cmd/cone/connector_consent.go new file mode 100644 index 00000000..7827b574 --- /dev/null +++ b/cmd/cone/connector_consent.go @@ -0,0 +1,119 @@ +package main + +import ( + "fmt" + + "github.com/conductorone/cone/pkg/consent" + "github.com/conductorone/cone/pkg/prompt" + "github.com/pterm/pterm" + "github.com/spf13/cobra" +) + +func connectorConsentCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "consent", + Short: "Manage consent for AI-assisted connector analysis", + Long: `Manage your consent for AI-assisted connector analysis features. + +AI-assisted analysis sends your connector source code to ConductorOne's +AI copilot for review and suggestions. This requires explicit consent. + +Without any flags, displays the current consent status. + +Examples: + cone connector consent # Check consent status + cone connector consent --agree # Grant consent (interactive) + cone connector consent --revoke # Revoke consent + cone connector consent --status # Explicit status check`, + RunE: runConnectorConsent, + } + + cmd.Flags().Bool("agree", false, "Grant consent for AI-assisted analysis (requires interactive terminal)") + cmd.Flags().Bool("revoke", false, "Revoke consent for AI-assisted analysis") + cmd.Flags().Bool("status", false, "Display consent status") + + return cmd +} + +func runConnectorConsent(cmd *cobra.Command, args []string) error { + agree, _ := cmd.Flags().GetBool("agree") + revoke, _ := cmd.Flags().GetBool("revoke") + status, _ := cmd.Flags().GetBool("status") + + // Validate mutually exclusive flags + flagCount := 0 + if agree { + flagCount++ + } + if revoke { + flagCount++ + } + if status { + flagCount++ + } + if flagCount > 1 { + return fmt.Errorf("only one of --agree, --revoke, or --status can be specified") + } + + // Handle revoke + if revoke { + if err := consent.Revoke(); err != nil { + return fmt.Errorf("failed to revoke consent: %w", err) + } + pterm.Success.Println("Consent revoked. AI-assisted analysis is now disabled.") + return nil + } + + // Handle status (explicit or default) + if status || (!agree && !revoke) { + fmt.Printf("Consent status: %s\n", consent.Status()) + return nil + } + + // Handle agree + if agree { + return grantConsent() + } + + return nil +} + +func grantConsent() error { + // Require interactive terminal for consent + if !prompt.IsInteractive() { + return fmt.Errorf("--agree requires an interactive terminal; cannot grant consent in non-interactive mode") + } + + // Check if already consented + if consent.HasValidConsent() { + pterm.Info.Println("You have already consented to AI-assisted analysis.") + fmt.Printf("Current status: %s\n", consent.Status()) + return nil + } + + // Display consent dialog + fmt.Println() + prompt.DisplayBox("AI-Assisted Analysis Consent", consent.ConsentText()) + fmt.Println() + + // Prompt for confirmation + confirmed, err := prompt.Confirm("Do you consent to AI-assisted analysis?") + if err != nil { + return fmt.Errorf("failed to get confirmation: %w", err) + } + + if !confirmed { + pterm.Warning.Println("Consent not granted. AI-assisted analysis remains disabled.") + return nil + } + + // Save consent + if err := consent.Save(); err != nil { + return fmt.Errorf("failed to save consent: %w", err) + } + + pterm.Success.Println("Consent granted. AI-assisted analysis is now enabled.") + fmt.Printf("You can revoke consent at any time with: cone connector consent --revoke\n") + + return nil +} diff --git a/cmd/cone/connector_dev.go b/cmd/cone/connector_dev.go new file mode 100644 index 00000000..4c32bee0 --- /dev/null +++ b/cmd/cone/connector_dev.go @@ -0,0 +1,250 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "os/signal" + "path/filepath" + "syscall" + "time" + + "github.com/fsnotify/fsnotify" + "github.com/spf13/cobra" +) + +// connectorDevCmd returns the command for running a local development server. +// It watches for file changes and automatically rebuilds/restarts the connector. +func connectorDevCmd() *cobra.Command { + var port int + var noWatch bool + + cmd := &cobra.Command{ + Use: "dev [path]", + Short: "Run a connector in development mode with hot reload", + Long: `Run a connector in development mode with automatic rebuilding on file changes. + +This command: +1. Builds the connector +2. Runs it with the specified flags +3. Watches for .go file changes +4. Automatically rebuilds and restarts on changes + +Press Ctrl+C to stop. + +Examples: + cone connector dev + cone connector dev ./my-connector + cone connector dev --no-watch`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + devPath := "." + if len(args) > 0 { + devPath = args[0] + } + + absPath, err := filepath.Abs(devPath) + if err != nil { + return fmt.Errorf("failed to resolve path: %w", err) + } + + // Check if directory contains go.mod + goModPath := filepath.Join(absPath, "go.mod") + if _, err := os.Stat(goModPath); os.IsNotExist(err) { + return fmt.Errorf("no go.mod found in %s - is this a Go project?", absPath) + } + + ctx, cancel := context.WithCancel(cmd.Context()) + defer cancel() + + // Handle shutdown signals + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + go func() { + <-sigCh + fmt.Println("\nShutting down...") + cancel() + }() + + if noWatch { + // Just build and run once + return buildAndRun(ctx, absPath, port) + } + + // Watch mode: rebuild on file changes + return watchAndRun(ctx, absPath, port) + }, + } + + cmd.Flags().IntVarP(&port, "port", "P", 8080, "Port for the connector to listen on (if applicable)") + cmd.Flags().BoolVar(&noWatch, "no-watch", false, "Disable file watching (run once)") + + return cmd +} + +// buildAndRun builds the connector and runs it. +func buildAndRun(ctx context.Context, path string, port int) error { + binaryPath := filepath.Join(path, "connector-dev") + + // Build + fmt.Println("Building connector...") + buildCmd := exec.CommandContext(ctx, "go", "build", "-o", binaryPath, ".") + buildCmd.Dir = path + buildCmd.Stdout = os.Stdout + buildCmd.Stderr = os.Stderr + + if err := buildCmd.Run(); err != nil { + return fmt.Errorf("build failed: %w", err) + } + + // Run + fmt.Printf("Starting connector (port %d)...\n", port) + runCmd := exec.CommandContext(ctx, binaryPath) + runCmd.Dir = path + runCmd.Stdout = os.Stdout + runCmd.Stderr = os.Stderr + runCmd.Env = append(os.Environ(), fmt.Sprintf("PORT=%d", port)) + + if err := runCmd.Run(); err != nil { + // Context cancelled is expected on shutdown + if ctx.Err() != nil { + return nil + } + return fmt.Errorf("connector exited with error: %w", err) + } + + return nil +} + +// watchAndRun watches for file changes and rebuilds/restarts the connector. +func watchAndRun(ctx context.Context, path string, port int) error { + watcher, err := fsnotify.NewWatcher() + if err != nil { + return fmt.Errorf("failed to create file watcher: %w", err) + } + defer watcher.Close() + + // Add directories to watch + if err := addWatchDirs(watcher, path); err != nil { + return fmt.Errorf("failed to watch directories: %w", err) + } + + var runCmd *exec.Cmd + var runCancel context.CancelFunc + binaryPath := filepath.Join(path, "connector-dev") + + // Initial build and run + build := func() error { + fmt.Println("\n[dev] Building connector...") + buildCmd := exec.CommandContext(ctx, "go", "build", "-o", binaryPath, ".") + buildCmd.Dir = path + buildCmd.Stdout = os.Stdout + buildCmd.Stderr = os.Stderr + + if err := buildCmd.Run(); err != nil { + fmt.Printf("[dev] Build failed: %v\n", err) + return err + } + fmt.Println("[dev] Build successful") + return nil + } + + start := func() { + // Stop previous run if any + if runCancel != nil { + runCancel() + } + if runCmd != nil && runCmd.Process != nil { + runCmd.Process.Kill() + runCmd.Wait() + } + + fmt.Printf("[dev] Starting connector (port %d)...\n", port) + var runCtx context.Context + runCtx, runCancel = context.WithCancel(ctx) + runCmd = exec.CommandContext(runCtx, binaryPath) + runCmd.Dir = path + runCmd.Stdout = os.Stdout + runCmd.Stderr = os.Stderr + runCmd.Env = append(os.Environ(), fmt.Sprintf("PORT=%d", port)) + + go func() { + if err := runCmd.Run(); err != nil && runCtx.Err() == nil { + fmt.Printf("[dev] Connector exited: %v\n", err) + } + }() + } + + // Initial build and run + if err := build(); err != nil { + fmt.Println("[dev] Initial build failed, waiting for file changes...") + } else { + start() + } + + // Debounce timer for file changes + var debounceTimer *time.Timer + debounce := func() { + if debounceTimer != nil { + debounceTimer.Stop() + } + debounceTimer = time.AfterFunc(500*time.Millisecond, func() { + if err := build(); err == nil { + start() + } + }) + } + + fmt.Println("[dev] Watching for file changes... (Ctrl+C to stop)") + + for { + select { + case <-ctx.Done(): + if runCancel != nil { + runCancel() + } + // Clean up binary + os.Remove(binaryPath) + return nil + + case event, ok := <-watcher.Events: + if !ok { + return nil + } + // Only watch .go files + if filepath.Ext(event.Name) == ".go" { + if event.Op&(fsnotify.Write|fsnotify.Create) != 0 { + fmt.Printf("[dev] Change detected: %s\n", filepath.Base(event.Name)) + debounce() + } + } + + case err, ok := <-watcher.Errors: + if !ok { + return nil + } + fmt.Printf("[dev] Watch error: %v\n", err) + } + } +} + +// addWatchDirs adds all directories containing .go files to the watcher. +func addWatchDirs(watcher *fsnotify.Watcher, root string) error { + return filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip vendor, .git, and other hidden directories + if info.IsDir() { + name := info.Name() + if name == "vendor" || name == ".git" || (name[0] == '.' && len(name) > 1) { + return filepath.SkipDir + } + return watcher.Add(path) + } + + return nil + }) +} diff --git a/cmd/cone/connector_init.go b/cmd/cone/connector_init.go new file mode 100644 index 00000000..13aa078a --- /dev/null +++ b/cmd/cone/connector_init.go @@ -0,0 +1,75 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/conductorone/cone/pkg/scaffold" + "github.com/spf13/cobra" +) + +// connectorInitCmd returns the command for initializing new connector projects. +func connectorInitCmd() *cobra.Command { + var modulePath string + var description string + + cmd := &cobra.Command{ + Use: "init ", + Short: "Create a new connector project", + Long: `Create a new ConductorOne connector project from the standard template. + +The project will be created in a directory named "baton-" in the current +working directory. + +Examples: + cone connector init my-app + cone connector init my-app --module github.com/myorg/baton-my-app + cone connector init my-app --description "Connector for My App"`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + + // Normalize name (remove baton- prefix if present) + name = strings.TrimPrefix(name, "baton-") + + // Determine output directory + outputDir := fmt.Sprintf("baton-%s", name) + + // Check if directory exists + if _, err := os.Stat(outputDir); !os.IsNotExist(err) { + return fmt.Errorf("directory already exists: %s", outputDir) + } + + cfg := &scaffold.Config{ + Name: name, + ModulePath: modulePath, + OutputDir: outputDir, + Description: description, + } + + fmt.Printf("Creating connector project: %s\n", outputDir) + + if err := scaffold.Generate(cfg); err != nil { + return fmt.Errorf("failed to generate project: %w", err) + } + + // Verify Go installation + fmt.Println("\nProject created successfully!") + fmt.Println("\nNext steps:") + fmt.Printf(" cd %s\n", outputDir) + fmt.Println(" go mod tidy") + fmt.Println(" # Edit pkg/client/client.go to implement API calls") + fmt.Println(" # Edit pkg/connector/*.go to implement resource syncers") + fmt.Println(" go build") + fmt.Println(" cone connector dev") + + return nil + }, + } + + cmd.Flags().StringVarP(&modulePath, "module", "m", "", "Go module path (default: github.com/conductorone/baton-)") + cmd.Flags().StringVarP(&description, "description", "d", "", "Connector description") + + return cmd +} diff --git a/cmd/cone/connector_publish.go b/cmd/cone/connector_publish.go new file mode 100644 index 00000000..3280cc8b --- /dev/null +++ b/cmd/cone/connector_publish.go @@ -0,0 +1,845 @@ +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + + c1client "github.com/conductorone/cone/pkg/client" + "github.com/spf13/cobra" +) + +// connectorPublishCmd creates the publish command for uploading connectors to the registry. +func connectorPublishCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "publish", + Short: "Publish connector to the ConductorOne registry", + Long: `Publish a connector version to the ConductorOne registry. + +This command performs the following steps: + 1. Reads connector metadata from go.mod and connector.yaml + 2. Finds built binaries in the dist/ directory + 3. Creates a new version in the registry + 4. Uploads binaries for each platform + 5. Uploads checksums + 6. Finalizes the version + +Prerequisites: + - Run 'cone login' first to authenticate + - Build binaries with 'make build' or 'goreleaser' + - Have a connector.yaml with metadata (optional but recommended)`, + Example: ` # Publish from current directory + cone connector publish --version v1.0.0 + + # Publish with specific binary directory + cone connector publish --version v1.0.0 --dist ./dist + + # Dry run to see what would be published + cone connector publish --version v1.0.0 --dry-run + + # Publish specific platforms only + cone connector publish --version v1.0.0 --platform linux-amd64 --platform darwin-arm64`, + RunE: runConnectorPublish, + } + + cmd.Flags().String("version", "", "Version to publish (e.g., v1.0.0)") + cmd.Flags().String("dist", "dist", "Directory containing built binaries") + cmd.Flags().StringSlice("platform", nil, "Platforms to publish (default: auto-detect)") + cmd.Flags().Bool("dry-run", false, "Show what would be published without publishing") + cmd.Flags().String("registry-url", "https://registry.conductorone.com", "Registry API URL") + cmd.Flags().String("signing-key", "", "Signing key ID for this release") + + cmd.MarkFlagRequired("version") + + return cmd +} + +func runConnectorPublish(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + // Get flags + version, _ := cmd.Flags().GetString("version") + distDir, _ := cmd.Flags().GetString("dist") + platforms, _ := cmd.Flags().GetStringSlice("platform") + dryRun, _ := cmd.Flags().GetBool("dry-run") + registryURL, _ := cmd.Flags().GetString("registry-url") + signingKey, _ := cmd.Flags().GetString("signing-key") + + // Validate version format + if !isValidVersion(version) { + return fmt.Errorf("invalid version format %q, expected semver like v1.0.0", version) + } + + // Read connector metadata + metadata, err := readConnectorMetadata() + if err != nil { + return fmt.Errorf("failed to read connector metadata: %w", err) + } + + fmt.Printf("Publishing %s/%s@%s\n", metadata.Org, metadata.Name, version) + + // Find binaries + binaries, err := findPublishBinaries(distDir, metadata.Name, platforms) + if err != nil { + return fmt.Errorf("failed to find binaries: %w", err) + } + + if len(binaries) == 0 { + return fmt.Errorf("no binaries found in %s", distDir) + } + + fmt.Printf("Found %d platform(s):\n", len(binaries)) + for _, b := range binaries { + fmt.Printf(" - %s (%s, %d bytes)\n", b.Platform, b.Filename, b.Size) + } + + if dryRun { + fmt.Println("\nDry run - no changes made") + return nil + } + + // Get auth token + token, err := getAuthToken(ctx, cmd) + if err != nil { + return fmt.Errorf("not authenticated, run 'cone login' first: %w", err) + } + + // Create registry client + client := newRegistryClient(registryURL, token) + + // Step 0: Ensure connector exists + fmt.Println("\nEnsuring connector exists...") + if err := client.EnsureConnector(ctx, metadata.Org, metadata.Name); err != nil { + return fmt.Errorf("failed to ensure connector exists: %w", err) + } + + // Step 1: Create version + fmt.Println("Creating version...") + platformNames := make([]string, len(binaries)) + for i, b := range binaries { + platformNames[i] = b.Platform + } + + _, err = client.CreateVersion(ctx, &createVersionRequest{ + Org: metadata.Org, + Name: metadata.Name, + Version: version, + Description: metadata.Description, + RepositoryURL: metadata.RepositoryURL, + HomepageURL: metadata.HomepageURL, + License: metadata.License, + Changelog: metadata.Changelog, + CommitSHA: getGitCommitSHA(), + Platforms: platformNames, + SigningKeyID: signingKey, + }) + if err != nil { + return fmt.Errorf("failed to create version: %w", err) + } + + fmt.Printf("Created version %s (state: PENDING)\n", version) + + // Step 2: Get upload URLs + fmt.Println("\nGetting upload URLs...") + uploadResp, err := client.GetUploadURLs(ctx, &getUploadURLsRequest{ + Org: metadata.Org, + Name: metadata.Name, + Version: version, + Platforms: platformNames, + }) + if err != nil { + return fmt.Errorf("failed to get upload URLs: %w", err) + } + + // Step 3: Upload binaries + fmt.Println("Uploading binaries...") + var assetMetadata []assetMeta + for _, binary := range binaries { + fmt.Printf(" Uploading %s...", binary.Platform) + + // Get upload URL + uploadKey := fmt.Sprintf("%s/binary", binary.Platform) + uploadURL, ok := uploadResp.UploadURLs[uploadKey] + if !ok { + fmt.Println(" SKIP (no upload URL)") + continue + } + + // Upload binary + if err := uploadFile(ctx, uploadURL, binary.Path); err != nil { + return fmt.Errorf("failed to upload %s: %w", binary.Platform, err) + } + + // Upload checksum + checksumKey := fmt.Sprintf("%s/checksum", binary.Platform) + if checksumURL, ok := uploadResp.UploadURLs[checksumKey]; ok { + checksumContent := fmt.Sprintf("%s %s\n", binary.Checksum, binary.Filename) + if err := uploadContent(ctx, checksumURL, []byte(checksumContent)); err != nil { + return fmt.Errorf("failed to upload checksum for %s: %w", binary.Platform, err) + } + } + + // Filename must match the registry's expected format: {name}-{version}-{platform}.tar.gz + registryFilename := fmt.Sprintf("%s-%s-%s.tar.gz", metadata.Name, version, binary.Platform) + assetMetadata = append(assetMetadata, assetMeta{ + Platform: binary.Platform, + Filename: registryFilename, + SHA256: binary.Checksum, + SizeBytes: binary.Size, + MediaType: "application/gzip", + }) + + fmt.Println(" OK") + } + + // Step 4: Finalize version + fmt.Println("\nFinalizing version...") + finalResp, err := client.FinalizeVersion(ctx, &finalizeVersionRequest{ + Org: metadata.Org, + Name: metadata.Name, + Version: version, + Assets: assetMetadata, + }) + if err != nil { + return fmt.Errorf("failed to finalize version: %w", err) + } + + if finalResp.Release.State == "FAILED" { + return fmt.Errorf("version validation failed: %s", finalResp.Release.FailureReason) + } + + fmt.Printf("\nPublished %s/%s@%s\n", metadata.Org, metadata.Name, version) + fmt.Printf("View at: %s/connectors/%s/%s\n", registryURL, metadata.Org, metadata.Name) + + return nil +} + +// connectorMetadata holds connector information for publishing. +type connectorMetadata struct { + Org string + Name string + Description string + RepositoryURL string + HomepageURL string + License string + Changelog string +} + +// readConnectorMetadata reads metadata from go.mod and connector.yaml. +func readConnectorMetadata() (*connectorMetadata, error) { + // Read module path from go.mod + modulePath, err := readModulePath() + if err != nil { + return nil, fmt.Errorf("failed to read go.mod: %w", err) + } + + // Parse org and name from module path + // Expected: github.com/org/baton-name + parts := strings.Split(modulePath, "/") + if len(parts) < 3 { + return nil, fmt.Errorf("invalid module path %q, expected github.com/org/name", modulePath) + } + + org := parts[len(parts)-2] + name := parts[len(parts)-1] + + // Strip "baton-" prefix if present for registry name + registryName := strings.TrimPrefix(name, "baton-") + + metadata := &connectorMetadata{ + Org: org, + Name: registryName, + } + + // Try to read connector.yaml for additional metadata + if data, err := os.ReadFile("connector.yaml"); err == nil { + parseConnectorYAML(data, metadata) + } + + // Default repository URL from module path + if metadata.RepositoryURL == "" { + metadata.RepositoryURL = "https://" + modulePath + } + + return metadata, nil +} + +// readModulePath reads the module path from go.mod. +func readModulePath() (string, error) { + data, err := os.ReadFile("go.mod") + if err != nil { + return "", err + } + + lines := strings.Split(string(data), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "module ") { + return strings.TrimPrefix(line, "module "), nil + } + } + + return "", fmt.Errorf("module directive not found in go.mod") +} + +// parseConnectorYAML parses connector.yaml into metadata. +// Simple YAML parsing without external dependency. +func parseConnectorYAML(data []byte, metadata *connectorMetadata) { + lines := strings.Split(string(data), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "description:") { + metadata.Description = strings.TrimSpace(strings.TrimPrefix(line, "description:")) + } else if strings.HasPrefix(line, "license:") { + metadata.License = strings.TrimSpace(strings.TrimPrefix(line, "license:")) + } else if strings.HasPrefix(line, "homepage_url:") { + metadata.HomepageURL = strings.TrimSpace(strings.TrimPrefix(line, "homepage_url:")) + } else if strings.HasPrefix(line, "repository_url:") { + metadata.RepositoryURL = strings.TrimSpace(strings.TrimPrefix(line, "repository_url:")) + } + } +} + +// publishBinary represents a binary to publish. +type publishBinary struct { + Platform string + Path string + Filename string + Checksum string + Size int64 +} + +// findPublishBinaries finds binaries in the dist directory. +func findPublishBinaries(distDir, connectorName string, platforms []string) ([]publishBinary, error) { + var binaries []publishBinary + + // If platforms specified, only look for those + if len(platforms) > 0 { + for _, platform := range platforms { + binary, err := findBinaryForPlatform(distDir, connectorName, platform) + if err != nil { + return nil, fmt.Errorf("platform %s: %w", platform, err) + } + binaries = append(binaries, *binary) + } + return binaries, nil + } + + // Auto-detect platforms by scanning dist directory + entries, err := os.ReadDir(distDir) + if err != nil { + return nil, err + } + + for _, entry := range entries { + if entry.IsDir() { + // Check for platform directories (e.g., linux_amd64, darwin_arm64) + platform := normalizePlatform(entry.Name()) + if platform != "" { + binary, err := findBinaryForPlatform(distDir, connectorName, platform) + if err == nil { + binaries = append(binaries, *binary) + } + } + } else { + // Check for direct binary files with platform suffix + name := entry.Name() + if strings.HasPrefix(name, connectorName) || strings.HasPrefix(name, "baton-"+connectorName) { + platform := extractPlatformFromFilename(name) + if platform != "" { + path := filepath.Join(distDir, name) + checksum, size, err := computeFileChecksum(path) + if err != nil { + continue + } + binaries = append(binaries, publishBinary{ + Platform: platform, + Path: path, + Filename: name, + Checksum: checksum, + Size: size, + }) + } + } + } + } + + return binaries, nil +} + +// findBinaryForPlatform finds a specific platform binary. +func findBinaryForPlatform(distDir, connectorName, platform string) (*publishBinary, error) { + // Try various naming conventions + patterns := []string{ + filepath.Join(distDir, platform, connectorName), + filepath.Join(distDir, platform, "baton-"+connectorName), + filepath.Join(distDir, strings.ReplaceAll(platform, "-", "_"), connectorName), + filepath.Join(distDir, strings.ReplaceAll(platform, "-", "_"), "baton-"+connectorName), + filepath.Join(distDir, fmt.Sprintf("%s_%s", connectorName, platform)), + filepath.Join(distDir, fmt.Sprintf("baton-%s_%s", connectorName, platform)), + } + + // Add .exe suffix for Windows + if strings.HasPrefix(platform, "windows") { + for i := range patterns { + patterns = append(patterns, patterns[i]+".exe") + } + } + + for _, path := range patterns { + if info, err := os.Stat(path); err == nil && !info.IsDir() { + checksum, size, err := computeFileChecksum(path) + if err != nil { + return nil, err + } + return &publishBinary{ + Platform: platform, + Path: path, + Filename: filepath.Base(path), + Checksum: checksum, + Size: size, + }, nil + } + } + + return nil, fmt.Errorf("binary not found") +} + +// normalizePlatform converts directory names to platform strings. +func normalizePlatform(name string) string { + // Convert goreleaser-style names (linux_amd64) to registry style (linux-amd64) + name = strings.ReplaceAll(name, "_", "-") + + // Validate it looks like a platform + parts := strings.Split(name, "-") + if len(parts) != 2 { + return "" + } + + os := parts[0] + arch := parts[1] + + validOS := map[string]bool{"linux": true, "darwin": true, "windows": true} + validArch := map[string]bool{"amd64": true, "arm64": true, "386": true} + + if validOS[os] && validArch[arch] { + return name + } + return "" +} + +// extractPlatformFromFilename extracts platform from filename. +func extractPlatformFromFilename(name string) string { + // Handle patterns like: baton-okta_linux_amd64, baton-okta-linux-amd64 + name = strings.TrimSuffix(name, ".exe") + + for _, sep := range []string{"_", "-"} { + parts := strings.Split(name, sep) + if len(parts) >= 3 { + os := parts[len(parts)-2] + arch := parts[len(parts)-1] + platform := normalizePlatform(os + "-" + arch) + if platform != "" { + return platform + } + } + } + + return "" +} + +// computeFileChecksum computes SHA256 checksum of a file. +func computeFileChecksum(path string) (string, int64, error) { + f, err := os.Open(path) + if err != nil { + return "", 0, err + } + defer f.Close() + + h := sha256.New() + size, err := io.Copy(h, f) + if err != nil { + return "", 0, err + } + + return hex.EncodeToString(h.Sum(nil)), size, nil +} + +// isValidVersion validates semantic version format. +func isValidVersion(v string) bool { + if !strings.HasPrefix(v, "v") { + return false + } + parts := strings.Split(strings.TrimPrefix(v, "v"), ".") + if len(parts) < 3 { + return false + } + // Basic validation - just check it starts with v and has dots + return true +} + +// getGitCommitSHA returns the current git commit SHA. +func getGitCommitSHA() string { + // Try to read from .git/HEAD + data, err := os.ReadFile(".git/HEAD") + if err != nil { + return "" + } + + content := strings.TrimSpace(string(data)) + if strings.HasPrefix(content, "ref: ") { + // It's a symbolic ref, read the actual ref + refPath := strings.TrimPrefix(content, "ref: ") + data, err = os.ReadFile(filepath.Join(".git", refPath)) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) + } + return content +} + +// getAuthToken retrieves the auth token for API calls using cone's OAuth credential flow. +func getAuthToken(ctx context.Context, cmd *cobra.Command) (string, error) { + // Allow env var override for CI/automation + if token := os.Getenv("CONE_REGISTRY_TOKEN"); token != "" { + return token, nil + } + + // Use cone's OAuth credential flow (same as other commands) + v, err := getSubViperForProfile(cmd) + if err != nil { + return "", fmt.Errorf("failed to get config: %w", err) + } + + clientId, clientSecret, err := getCredentials(v) + if err != nil { + return "", fmt.Errorf("no credentials available. Run 'cone login' first: %w", err) + } + + tokenSrc, _, _, err := c1client.NewC1TokenSource(ctx, clientId, clientSecret, v.GetString("api-endpoint"), v.GetBool("debug")) + if err != nil { + return "", fmt.Errorf("failed to create token source: %w", err) + } + + token, err := tokenSrc.Token() + if err != nil { + return "", fmt.Errorf("failed to get auth token: %w", err) + } + + return token.AccessToken, nil +} + +// Registry client types and methods + +type registryClient struct { + baseURL string + token string + httpClient *http.Client +} + +func newRegistryClient(baseURL, token string) *registryClient { + return ®istryClient{ + baseURL: baseURL, + token: token, + httpClient: &http.Client{}, + } +} + +type createConnectorRequest struct { + Org string `json:"org"` + Name string `json:"name"` +} + +type createConnectorResponse struct { + Connector connectorInfo `json:"connector"` +} + +type connectorInfo struct { + Org string `json:"org"` + Name string `json:"name"` +} + +type createVersionRequest struct { + Org string `json:"org"` + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description,omitempty"` + RepositoryURL string `json:"repository_url,omitempty"` + HomepageURL string `json:"homepage_url,omitempty"` + License string `json:"license,omitempty"` + Changelog string `json:"changelog,omitempty"` + CommitSHA string `json:"commit_sha,omitempty"` + Platforms []string `json:"platforms"` + SigningKeyID string `json:"signing_key_id,omitempty"` +} + +type createVersionResponse struct { + Release releaseManifest `json:"release"` +} + +type getUploadURLsRequest struct { + Org string `json:"org"` + Name string `json:"name"` + Version string `json:"version"` + Platforms []string `json:"platforms"` +} + +type getUploadURLsResponse struct { + UploadURLs map[string]string `json:"uploadUrls"` +} + +type releaseManifest struct { + Org string `json:"org"` + Name string `json:"name"` + Version string `json:"version"` + State string `json:"state"` + FailureReason string `json:"failure_reason,omitempty"` +} + +type finalizeVersionRequest struct { + Org string `json:"org"` + Name string `json:"name"` + Version string `json:"version"` + Assets []assetMeta `json:"assets"` +} + +type assetMeta struct { + Platform string `json:"platform"` + Filename string `json:"filename"` + SHA256 string `json:"sha256"` + SizeBytes int64 `json:"size_bytes"` + MediaType string `json:"media_type"` +} + +type finalizeVersionResponse struct { + Release releaseManifest `json:"release"` +} + +// EnsureConnector creates the connector if it doesn't exist. +// Returns nil if connector already exists or was created successfully. +func (c *registryClient) EnsureConnector(ctx context.Context, org, name string) error { + url := fmt.Sprintf("%s/api/v1/connectors", c.baseURL) + + reqBody := createConnectorRequest{Org: org, Name: name} + body, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + if c.token != "" { + httpReq.Header.Set("Authorization", "Bearer "+c.token) + } + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + // 200 OK = connector returned (already exists or created) + // 201 Created = new connector + // 409 Conflict = already exists (that's fine) + if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusConflict { + return nil + } + + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("failed to ensure connector exists (status %d): %s", resp.StatusCode, string(respBody)) +} + +func (c *registryClient) CreateVersion(ctx context.Context, req *createVersionRequest) (*createVersionResponse, error) { + url := fmt.Sprintf("%s/api/v1/connectors/%s/%s/versions", c.baseURL, req.Org, req.Name) + + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + if c.token != "" { + httpReq.Header.Set("Authorization", "Bearer "+c.token) + } + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody)) + } + + var result createVersionResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &result, nil +} + +func (c *registryClient) GetUploadURLs(ctx context.Context, req *getUploadURLsRequest) (*getUploadURLsResponse, error) { + url := fmt.Sprintf("%s/api/v1/connectors/%s/%s/versions/%s/upload-urls", c.baseURL, req.Org, req.Name, req.Version) + + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + if c.token != "" { + httpReq.Header.Set("Authorization", "Bearer "+c.token) + } + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody)) + } + + var result getUploadURLsResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &result, nil +} + +func (c *registryClient) FinalizeVersion(ctx context.Context, req *finalizeVersionRequest) (*finalizeVersionResponse, error) { + url := fmt.Sprintf("%s/api/v1/connectors/%s/%s/versions/%s/finalize", c.baseURL, req.Org, req.Name, req.Version) + + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + if c.token != "" { + httpReq.Header.Set("Authorization", "Bearer "+c.token) + } + + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(respBody)) + } + + var result finalizeVersionResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &result, nil +} + +func uploadFile(ctx context.Context, url, path string) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + + info, err := f.Stat() + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, "PUT", url, f) + if err != nil { + return err + } + req.ContentLength = info.Size() + req.Header.Set("Content-Type", "application/octet-stream") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return fmt.Errorf("upload failed with status %d", resp.StatusCode) + } + + return nil +} + +func uploadContent(ctx context.Context, url string, content []byte) error { + req, err := http.NewRequestWithContext(ctx, "PUT", url, strings.NewReader(string(content))) + if err != nil { + return err + } + req.ContentLength = int64(len(content)) + req.Header.Set("Content-Type", "text/plain") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return fmt.Errorf("upload failed with status %d", resp.StatusCode) + } + + return nil +} + +// getCurrentPlatform returns the current OS/arch. +func getCurrentPlatform() string { + return fmt.Sprintf("%s-%s", runtime.GOOS, runtime.GOARCH) +} diff --git a/cmd/cone/connector_test.go b/cmd/cone/connector_test.go new file mode 100644 index 00000000..3623e062 --- /dev/null +++ b/cmd/cone/connector_test.go @@ -0,0 +1,48 @@ +package main + +import ( + "testing" +) + +func TestConnectorCmd(t *testing.T) { + cmd := connectorCmd() + + if cmd.Use != "connector" { + t.Errorf("expected Use to be 'connector', got %s", cmd.Use) + } + if cmd.Short != "Manage ConductorOne connectors" { + t.Errorf("expected Short to be 'Manage ConductorOne connectors', got %s", cmd.Short) + } + if !cmd.HasSubCommands() { + t.Error("connector command should have subcommands") + } +} + +func TestConnectorBuildCmd(t *testing.T) { + cmd := connectorBuildCmd() + + if cmd.Use != "build [path]" { + t.Errorf("expected Use to be 'build [path]', got %s", cmd.Use) + } + if cmd.Short != "Build a connector binary" { + t.Errorf("expected Short to be 'Build a connector binary', got %s", cmd.Short) + } + + // Verify flags exist + outputFlag := cmd.Flag("output") + if outputFlag == nil { + t.Error("should have --output flag") + } else if outputFlag.Shorthand != "o" { + t.Errorf("expected output shorthand to be 'o', got %s", outputFlag.Shorthand) + } + + osFlag := cmd.Flag("os") + if osFlag == nil { + t.Error("should have --os flag") + } + + archFlag := cmd.Flag("arch") + if archFlag == nil { + t.Error("should have --arch flag") + } +} diff --git a/cmd/cone/connector_validate.go b/cmd/cone/connector_validate.go new file mode 100644 index 00000000..4f77df29 --- /dev/null +++ b/cmd/cone/connector_validate.go @@ -0,0 +1,262 @@ +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +func connectorValidateConfigCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "validate-config ", + Short: "Validate a meta-connector mapping configuration", + Long: `Validate a mapping configuration file for meta-connectors like baton-openapi. + +This checks: + - Required fields are present + - Field values are valid + - At least one TRAIT_USER resource exists + - Entitlements have grantable_to defined + - No duplicate resource types`, + Example: ` cone connector validate-config mapping.yaml + cone connector validate-config examples/github/mapping.yaml`, + Args: cobra.ExactArgs(1), + RunE: runValidateConfig, + } + return cmd +} + +func runValidateConfig(cmd *cobra.Command, args []string) error { + configPath := args[0] + + data, err := os.ReadFile(configPath) + if err != nil { + return fmt.Errorf("failed to read file: %w", err) + } + + var config MappingConfig + if err := yaml.Unmarshal(data, &config); err != nil { + return fmt.Errorf("failed to parse YAML: %w", err) + } + + if err := config.Validate(); err != nil { + return err + } + + fmt.Printf("Valid: %s\n", configPath) + fmt.Printf(" Name: %s\n", config.Name) + fmt.Printf(" Resources: %d\n", len(config.Resources)) + for _, r := range config.Resources { + entCount := len(r.Entitlements) + fmt.Printf(" - %s (%s) [%d entitlements]\n", r.Type, r.Trait, entCount) + } + + return nil +} + +// MappingConfig is the root configuration for meta-connectors. +type MappingConfig struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Resources []ResourceConfig `yaml:"resources"` +} + +// ResourceConfig defines how to sync a resource type. +type ResourceConfig struct { + Type string `yaml:"type"` + DisplayName string `yaml:"display_name"` + Trait string `yaml:"trait"` + List ListConfig `yaml:"list"` + Fields FieldMapping `yaml:"fields"` + Entitlements []EntitlementConfig `yaml:"entitlements,omitempty"` +} + +// ListConfig defines how to list resources. +type ListConfig struct { + Endpoint string `yaml:"endpoint"` + Method string `yaml:"method,omitempty"` + ResponsePath string `yaml:"response_path,omitempty"` + Pagination *PaginationConfig `yaml:"pagination,omitempty"` +} + +// PaginationConfig defines pagination behavior. +type PaginationConfig struct { + Type string `yaml:"type"` + CursorParam string `yaml:"cursor_param,omitempty"` + CursorPath string `yaml:"cursor_path,omitempty"` + OffsetParam string `yaml:"offset_param,omitempty"` + LimitParam string `yaml:"limit_param,omitempty"` + PageSize int `yaml:"page_size,omitempty"` +} + +// FieldMapping maps API fields to baton fields. +type FieldMapping struct { + ID string `yaml:"id"` + DisplayName string `yaml:"display_name"` + Email string `yaml:"email,omitempty"` + Description string `yaml:"description,omitempty"` + Status string `yaml:"status,omitempty"` + Profile map[string]string `yaml:"profile,omitempty"` +} + +// EntitlementConfig defines an entitlement. +type EntitlementConfig struct { + ID string `yaml:"id"` + DisplayName string `yaml:"display_name"` + Description string `yaml:"description,omitempty"` + GrantableTo []string `yaml:"grantable_to"` + Grants *GrantsConfig `yaml:"grants,omitempty"` + Provisioning *ProvisionConfig `yaml:"provisioning,omitempty"` +} + +// GrantsConfig defines how to fetch grants. +type GrantsConfig struct { + Endpoint string `yaml:"endpoint"` + Method string `yaml:"method,omitempty"` + ResponsePath string `yaml:"response_path,omitempty"` + PrincipalIDPath string `yaml:"principal_id_path"` + PrincipalType string `yaml:"principal_type"` +} + +// ProvisionConfig defines provisioning actions. +type ProvisionConfig struct { + Grant *ProvisionAction `yaml:"grant,omitempty"` + Revoke *ProvisionAction `yaml:"revoke,omitempty"` +} + +// ProvisionAction defines a single provisioning call. +type ProvisionAction struct { + Endpoint string `yaml:"endpoint"` + Method string `yaml:"method"` + Body map[string]any `yaml:"body,omitempty"` +} + +// Validate checks the configuration for errors. +func (c *MappingConfig) Validate() error { + var errs []string + + if c.Name == "" { + errs = append(errs, "name is required") + } + + if len(c.Resources) == 0 { + errs = append(errs, "at least one resource is required") + } + + hasUser := false + resourceTypes := make(map[string]bool) + validTraits := map[string]bool{ + "": true, + "TRAIT_USER": true, + "TRAIT_GROUP": true, + "TRAIT_ROLE": true, + "TRAIT_APP": true, + } + + for i, r := range c.Resources { + prefix := fmt.Sprintf("resources[%d]", i) + if r.Type != "" { + prefix = fmt.Sprintf("resources[%d] (%s)", i, r.Type) + } + + if r.Type == "" { + errs = append(errs, fmt.Sprintf("%s: type is required", prefix)) + } else if resourceTypes[r.Type] { + errs = append(errs, fmt.Sprintf("%s: duplicate resource type", prefix)) + } else { + resourceTypes[r.Type] = true + } + + if !validTraits[r.Trait] { + errs = append(errs, fmt.Sprintf("%s: invalid trait %q", prefix, r.Trait)) + } + + if r.Trait == "TRAIT_USER" { + hasUser = true + } + + if r.List.Endpoint == "" { + errs = append(errs, fmt.Sprintf("%s: list.endpoint is required", prefix)) + } + + if r.Fields.ID == "" { + errs = append(errs, fmt.Sprintf("%s: fields.id is required", prefix)) + } + + if r.List.Pagination != nil { + pag := r.List.Pagination + validPagTypes := map[string]bool{"cursor": true, "offset": true, "page": true, "link": true} + if !validPagTypes[pag.Type] { + errs = append(errs, fmt.Sprintf("%s: invalid pagination type %q", prefix, pag.Type)) + } + if pag.Type == "cursor" && (pag.CursorParam == "" || pag.CursorPath == "") { + errs = append(errs, fmt.Sprintf("%s: cursor pagination requires cursor_param and cursor_path", prefix)) + } + if pag.Type == "offset" && (pag.OffsetParam == "" || pag.LimitParam == "") { + errs = append(errs, fmt.Sprintf("%s: offset pagination requires offset_param and limit_param", prefix)) + } + } + + for j, e := range r.Entitlements { + eprefix := fmt.Sprintf("%s.entitlements[%d]", prefix, j) + if e.ID != "" { + eprefix = fmt.Sprintf("%s.entitlements[%d] (%s)", prefix, j, e.ID) + } + + if e.ID == "" { + errs = append(errs, fmt.Sprintf("%s: id is required", eprefix)) + } + + if len(e.GrantableTo) == 0 { + errs = append(errs, fmt.Sprintf("%s: grantable_to is required", eprefix)) + } + + if e.Grants != nil { + if e.Grants.Endpoint == "" { + errs = append(errs, fmt.Sprintf("%s.grants: endpoint is required", eprefix)) + } + if e.Grants.PrincipalIDPath == "" { + errs = append(errs, fmt.Sprintf("%s.grants: principal_id_path is required", eprefix)) + } + if e.Grants.PrincipalType == "" { + errs = append(errs, fmt.Sprintf("%s.grants: principal_type is required", eprefix)) + } + } + + if e.Provisioning != nil { + if e.Provisioning.Grant != nil { + if e.Provisioning.Grant.Endpoint == "" { + errs = append(errs, fmt.Sprintf("%s.provisioning.grant: endpoint is required", eprefix)) + } + if e.Provisioning.Grant.Method == "" { + errs = append(errs, fmt.Sprintf("%s.provisioning.grant: method is required", eprefix)) + } + } + if e.Provisioning.Revoke != nil { + if e.Provisioning.Revoke.Endpoint == "" { + errs = append(errs, fmt.Sprintf("%s.provisioning.revoke: endpoint is required", eprefix)) + } + if e.Provisioning.Revoke.Method == "" { + errs = append(errs, fmt.Sprintf("%s.provisioning.revoke: method is required", eprefix)) + } + } + } + } + } + + if !hasUser && len(c.Resources) > 0 { + errs = append(errs, "at least one resource with TRAIT_USER is required") + } + + if len(errs) > 0 { + msg := fmt.Sprintf("validation failed (%d errors):", len(errs)) + for _, e := range errs { + msg += fmt.Sprintf("\n - %s", e) + } + return fmt.Errorf("%s", msg) + } + + return nil +} diff --git a/cmd/cone/main.go b/cmd/cone/main.go index dc264b2e..b5a9ba2a 100644 --- a/cmd/cone/main.go +++ b/cmd/cone/main.go @@ -80,6 +80,7 @@ func runCli(ctx context.Context) int { cliCmd.AddCommand(hasCmd()) cliCmd.AddCommand(tokenCmd()) cliCmd.AddCommand(decryptCredentialCmd()) + cliCmd.AddCommand(connectorCmd()) err = cliCmd.ExecuteContext(ctx) if err != nil { diff --git a/pkg/consent/consent.go b/pkg/consent/consent.go new file mode 100644 index 00000000..e9ef61d2 --- /dev/null +++ b/pkg/consent/consent.go @@ -0,0 +1,198 @@ +// Package consent manages user consent for AI-assisted features that send code to C1. +// +// Security rationale for design decisions: +// - Consent stored in ~/.cone/consent.json (separate from ~/.conductorone/ credentials) +// - File permissions: 0600 (user-only read/write) to prevent other users from modifying +// - Version tracking enables re-prompting when consent terms change +// - Requires interactive terminal for --agree to prevent scripted consent bypass +package consent + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" +) + +// CurrentConsentVersion should be incremented when consent text changes materially. +// This triggers re-prompting users who consented to a previous version. +const CurrentConsentVersion = "1.0" + +// ConsentRecord stores the user's consent decision. +type ConsentRecord struct { + ConsentedAt time.Time `json:"consented_at"` + Version string `json:"version"` +} + +// ErrNoConsent is returned when the user has not given consent. +var ErrNoConsent = errors.New("consent: user has not consented to AI-assisted analysis") + +// ErrConsentVersionMismatch is returned when consent version is outdated. +var ErrConsentVersionMismatch = errors.New("consent: consent version has changed, re-consent required") + +// consentDir returns the path to the cone config directory. +func consentDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get home directory: %w", err) + } + return filepath.Join(home, ".cone"), nil +} + +// consentFilePath returns the path to the consent file. +func consentFilePath() (string, error) { + dir, err := consentDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "consent.json"), nil +} + +// ensureConsentDir ensures ~/.cone directory exists with correct permissions. +// Security: 0700 permissions (rwx------) prevent other users from listing contents. +func ensureConsentDir() error { + dir, err := consentDir() + if err != nil { + return err + } + + // Create with restrictive permissions (0700 = rwx------) + if err := os.MkdirAll(dir, 0700); err != nil { + return fmt.Errorf("failed to create .cone directory: %w", err) + } + + // Verify permissions in case directory already existed with wrong perms + info, err := os.Stat(dir) + if err != nil { + return err + } + if info.Mode().Perm() != 0700 { + if err := os.Chmod(dir, 0700); err != nil { + return fmt.Errorf("failed to set directory permissions: %w", err) + } + } + + return nil +} + +// Load reads the consent record from disk. +// Returns ErrNoConsent if no consent file exists. +// Returns ErrConsentVersionMismatch if consent version doesn't match current. +func Load() (*ConsentRecord, error) { + path, err := consentFilePath() + if err != nil { + return nil, err + } + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, ErrNoConsent + } + return nil, fmt.Errorf("failed to read consent file: %w", err) + } + + var record ConsentRecord + if err := json.Unmarshal(data, &record); err != nil { + return nil, fmt.Errorf("failed to parse consent file: %w", err) + } + + if record.Version != CurrentConsentVersion { + return &record, ErrConsentVersionMismatch + } + + return &record, nil +} + +// HasValidConsent returns true if the user has valid, current consent. +func HasValidConsent() bool { + _, err := Load() + return err == nil +} + +// Save writes a new consent record to disk. +// Security: File written with 0600 permissions (rw-------). +func Save() error { + if err := ensureConsentDir(); err != nil { + return err + } + + path, err := consentFilePath() + if err != nil { + return err + } + + record := ConsentRecord{ + ConsentedAt: time.Now().UTC(), + Version: CurrentConsentVersion, + } + + data, err := json.MarshalIndent(record, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal consent record: %w", err) + } + + // Write with restrictive permissions (0600 = rw-------) + if err := os.WriteFile(path, data, 0600); err != nil { + return fmt.Errorf("failed to write consent file: %w", err) + } + + return nil +} + +// Revoke removes the consent record from disk. +func Revoke() error { + path, err := consentFilePath() + if err != nil { + return err + } + + if err := os.Remove(path); err != nil { + if os.IsNotExist(err) { + return nil // Already revoked + } + return fmt.Errorf("failed to remove consent file: %w", err) + } + + return nil +} + +// Status returns a human-readable string describing consent status. +func Status() string { + record, err := Load() + if err != nil { + if errors.Is(err, ErrNoConsent) { + return "Not consented" + } + if errors.Is(err, ErrConsentVersionMismatch) { + return fmt.Sprintf("Consent outdated (v%s, current is v%s)", record.Version, CurrentConsentVersion) + } + return fmt.Sprintf("Error checking consent: %v", err) + } + return fmt.Sprintf("Consented on %s (v%s)", record.ConsentedAt.Format(time.RFC3339), record.Version) +} + +// ConsentText returns the full consent text to display to users. +func ConsentText() string { + return `AI-Assisted Connector Analysis Consent + +This command sends your connector source code to ConductorOne for AI analysis. + +What happens: + - Your connector code is sent to ConductorOne's AI copilot + - The AI analyzes your code and suggests improvements + - Your code is processed in memory and is NOT stored permanently + - Analysis results are returned to your local machine + +Your code is: + - Processed only for the duration of the analysis + - Not used for AI training + - Not shared with third parties + - Subject to ConductorOne's privacy policy + +For more information, see: https://www.conductorone.com/privacy + +Do you consent to AI-assisted analysis of your connector code?` +} diff --git a/pkg/consent/consent_test.go b/pkg/consent/consent_test.go new file mode 100644 index 00000000..e7c76302 --- /dev/null +++ b/pkg/consent/consent_test.go @@ -0,0 +1,528 @@ +package consent + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" +) + +// setupTestDir creates a temp directory and sets HOME to point to it. +// Returns a cleanup function that restores the original HOME. +func setupTestDir(t *testing.T) func() { + t.Helper() + + originalHome := os.Getenv("HOME") + tmpDir := t.TempDir() + if err := os.Setenv("HOME", tmpDir); err != nil { + t.Fatalf("failed to set HOME: %v", err) + } + + return func() { + os.Setenv("HOME", originalHome) + } +} + +func TestConsentDir(t *testing.T) { + cleanup := setupTestDir(t) + defer cleanup() + + dir, err := consentDir() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !contains(dir, ".cone") { + t.Errorf("expected dir to contain .cone, got %s", dir) + } +} + +func TestConsentFilePath(t *testing.T) { + cleanup := setupTestDir(t) + defer cleanup() + + path, err := consentFilePath() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !contains(path, "consent.json") { + t.Errorf("expected path to contain consent.json, got %s", path) + } + if !contains(path, ".cone") { + t.Errorf("expected path to contain .cone, got %s", path) + } +} + +func TestEnsureConsentDir(t *testing.T) { + cleanup := setupTestDir(t) + defer cleanup() + + if err := ensureConsentDir(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + dir, err := consentDir() + if err != nil { + t.Fatalf("failed to get consent dir: %v", err) + } + + info, err := os.Stat(dir) + if err != nil { + t.Fatalf("failed to stat directory: %v", err) + } + if !info.IsDir() { + t.Error("expected directory, got file") + } + if info.Mode().Perm() != 0700 { + t.Errorf("expected permissions 0700, got %o", info.Mode().Perm()) + } +} + +func TestLoad_NoConsent(t *testing.T) { + cleanup := setupTestDir(t) + defer cleanup() + + record, err := Load() + if record != nil { + t.Error("expected nil record") + } + if err != ErrNoConsent { + t.Errorf("expected ErrNoConsent, got %v", err) + } +} + +func TestLoad_ValidConsent(t *testing.T) { + cleanup := setupTestDir(t) + defer cleanup() + + // Create consent file manually + if err := ensureConsentDir(); err != nil { + t.Fatalf("failed to ensure dir: %v", err) + } + + path, err := consentFilePath() + if err != nil { + t.Fatalf("failed to get path: %v", err) + } + + record := ConsentRecord{ + ConsentedAt: time.Now().UTC(), + Version: CurrentConsentVersion, + } + data, err := json.Marshal(record) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatalf("failed to write file: %v", err) + } + + // Load and verify + loaded, err := Load() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if loaded.Version != CurrentConsentVersion { + t.Errorf("expected version %s, got %s", CurrentConsentVersion, loaded.Version) + } + if loaded.ConsentedAt.IsZero() { + t.Error("expected non-zero consented_at") + } +} + +func TestLoad_VersionMismatch(t *testing.T) { + cleanup := setupTestDir(t) + defer cleanup() + + // Create consent file with old version + if err := ensureConsentDir(); err != nil { + t.Fatalf("failed to ensure dir: %v", err) + } + + path, err := consentFilePath() + if err != nil { + t.Fatalf("failed to get path: %v", err) + } + + record := ConsentRecord{ + ConsentedAt: time.Now().UTC(), + Version: "0.9", // Old version + } + data, err := json.Marshal(record) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatalf("failed to write file: %v", err) + } + + // Load should return version mismatch error + loaded, err := Load() + if loaded == nil { + t.Error("expected record to be returned even on version mismatch") + } + if err != ErrConsentVersionMismatch { + t.Errorf("expected ErrConsentVersionMismatch, got %v", err) + } + if loaded != nil && loaded.Version != "0.9" { + t.Errorf("expected version 0.9, got %s", loaded.Version) + } +} + +func TestLoad_InvalidJSON(t *testing.T) { + cleanup := setupTestDir(t) + defer cleanup() + + if err := ensureConsentDir(); err != nil { + t.Fatalf("failed to ensure dir: %v", err) + } + + path, err := consentFilePath() + if err != nil { + t.Fatalf("failed to get path: %v", err) + } + + if err := os.WriteFile(path, []byte("not valid json"), 0600); err != nil { + t.Fatalf("failed to write file: %v", err) + } + + record, err := Load() + if record != nil { + t.Error("expected nil record for invalid JSON") + } + if err == nil { + t.Error("expected error for invalid JSON") + } + if !contains(err.Error(), "failed to parse consent file") { + t.Errorf("expected parse error, got: %v", err) + } +} + +func TestHasValidConsent_NoConsent(t *testing.T) { + cleanup := setupTestDir(t) + defer cleanup() + + if HasValidConsent() { + t.Error("expected no valid consent") + } +} + +func TestHasValidConsent_WithConsent(t *testing.T) { + cleanup := setupTestDir(t) + defer cleanup() + + if err := Save(); err != nil { + t.Fatalf("failed to save: %v", err) + } + + if !HasValidConsent() { + t.Error("expected valid consent") + } +} + +func TestSave(t *testing.T) { + cleanup := setupTestDir(t) + defer cleanup() + + if err := Save(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify file exists with correct permissions + path, err := consentFilePath() + if err != nil { + t.Fatalf("failed to get path: %v", err) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("failed to stat file: %v", err) + } + if info.Mode().Perm() != 0600 { + t.Errorf("expected permissions 0600, got %o", info.Mode().Perm()) + } + + // Verify content + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read file: %v", err) + } + + var record ConsentRecord + if err := json.Unmarshal(data, &record); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + if record.Version != CurrentConsentVersion { + t.Errorf("expected version %s, got %s", CurrentConsentVersion, record.Version) + } + if record.ConsentedAt.IsZero() { + t.Error("expected non-zero consented_at") + } +} + +func TestRevoke(t *testing.T) { + cleanup := setupTestDir(t) + defer cleanup() + + // Save then revoke + if err := Save(); err != nil { + t.Fatalf("failed to save: %v", err) + } + if !HasValidConsent() { + t.Error("expected valid consent after save") + } + + if err := Revoke(); err != nil { + t.Fatalf("failed to revoke: %v", err) + } + if HasValidConsent() { + t.Error("expected no valid consent after revoke") + } +} + +func TestRevoke_NoConsent(t *testing.T) { + cleanup := setupTestDir(t) + defer cleanup() + + // Revoking when no consent exists should not error + if err := Revoke(); err != nil { + t.Errorf("unexpected error: %v", err) + } +} + +func TestStatus_NotConsented(t *testing.T) { + cleanup := setupTestDir(t) + defer cleanup() + + status := Status() + if status != "Not consented" { + t.Errorf("expected 'Not consented', got %s", status) + } +} + +func TestStatus_Consented(t *testing.T) { + cleanup := setupTestDir(t) + defer cleanup() + + if err := Save(); err != nil { + t.Fatalf("failed to save: %v", err) + } + + status := Status() + if !contains(status, "Consented on") { + t.Errorf("expected status to contain 'Consented on', got %s", status) + } + if !contains(status, CurrentConsentVersion) { + t.Errorf("expected status to contain version %s, got %s", CurrentConsentVersion, status) + } +} + +func TestStatus_VersionMismatch(t *testing.T) { + cleanup := setupTestDir(t) + defer cleanup() + + // Create old version consent + if err := ensureConsentDir(); err != nil { + t.Fatalf("failed to ensure dir: %v", err) + } + + path, err := consentFilePath() + if err != nil { + t.Fatalf("failed to get path: %v", err) + } + + record := ConsentRecord{ + ConsentedAt: time.Now().UTC(), + Version: "0.9", + } + data, err := json.Marshal(record) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatalf("failed to write file: %v", err) + } + + status := Status() + if !contains(status, "outdated") { + t.Errorf("expected status to contain 'outdated', got %s", status) + } + if !contains(status, "0.9") { + t.Errorf("expected status to contain '0.9', got %s", status) + } +} + +func TestConsentText(t *testing.T) { + text := ConsentText() + if !contains(text, "AI-Assisted Connector Analysis") { + t.Error("expected consent text to contain 'AI-Assisted Connector Analysis'") + } + if !contains(text, "ConductorOne") { + t.Error("expected consent text to contain 'ConductorOne'") + } + if !contains(text, "privacy") { + t.Error("expected consent text to contain 'privacy'") + } +} + +func TestCurrentConsentVersion(t *testing.T) { + if CurrentConsentVersion == "" { + t.Error("expected non-empty consent version") + } + if CurrentConsentVersion != "1.0" { + t.Errorf("expected version 1.0, got %s", CurrentConsentVersion) + } +} + +func TestDirectoryPermissions(t *testing.T) { + cleanup := setupTestDir(t) + defer cleanup() + + // Create directory with wrong permissions + dir, err := consentDir() + if err != nil { + t.Fatalf("failed to get dir: %v", err) + } + + if err := os.MkdirAll(dir, 0755); err != nil { // Wrong permissions + t.Fatalf("failed to create dir: %v", err) + } + + // ensureConsentDir should fix them + if err := ensureConsentDir(); err != nil { + t.Fatalf("failed to ensure dir: %v", err) + } + + info, err := os.Stat(dir) + if err != nil { + t.Fatalf("failed to stat dir: %v", err) + } + if info.Mode().Perm() != 0700 { + t.Errorf("expected permissions 0700, got %o", info.Mode().Perm()) + } +} + +func TestConsentRecordSerialization(t *testing.T) { + now := time.Date(2026, 1, 25, 12, 0, 0, 0, time.UTC) + record := ConsentRecord{ + ConsentedAt: now, + Version: "1.0", + } + + data, err := json.Marshal(record) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + + var decoded ConsentRecord + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + + if decoded.Version != record.Version { + t.Errorf("expected version %s, got %s", record.Version, decoded.Version) + } + if !record.ConsentedAt.Equal(decoded.ConsentedAt) { + t.Errorf("timestamps don't match: %v vs %v", record.ConsentedAt, decoded.ConsentedAt) + } +} + +func TestConsentDirCreatesParentDirectories(t *testing.T) { + cleanup := setupTestDir(t) + defer cleanup() + + // Verify the directory doesn't exist yet + dir, err := consentDir() + if err != nil { + t.Fatalf("failed to get dir: %v", err) + } + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Error("expected directory to not exist initially") + } + + // Save should create the directory + if err := Save(); err != nil { + t.Fatalf("failed to save: %v", err) + } + + // Verify directory now exists + info, err := os.Stat(dir) + if err != nil { + t.Fatalf("failed to stat dir: %v", err) + } + if !info.IsDir() { + t.Error("expected directory") + } +} + +func TestMultipleSaveOverwrites(t *testing.T) { + cleanup := setupTestDir(t) + defer cleanup() + + // Save twice + if err := Save(); err != nil { + t.Fatalf("first save failed: %v", err) + } + + time.Sleep(10 * time.Millisecond) // Ensure different timestamp + + if err := Save(); err != nil { + t.Fatalf("second save failed: %v", err) + } + + // Should still be valid + if !HasValidConsent() { + t.Error("expected valid consent") + } + + // Only one file should exist + dir, err := consentDir() + if err != nil { + t.Fatalf("failed to get dir: %v", err) + } + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("failed to read dir: %v", err) + } + if len(entries) != 1 { + t.Errorf("expected 1 entry, got %d", len(entries)) + } + if entries[0].Name() != "consent.json" { + t.Errorf("expected consent.json, got %s", entries[0].Name()) + } +} + +func TestConsentFileInSubdirectory(t *testing.T) { + cleanup := setupTestDir(t) + defer cleanup() + + path, err := consentFilePath() + if err != nil { + t.Fatalf("failed to get path: %v", err) + } + + // Should be in .cone subdirectory + dir := filepath.Dir(path) + if filepath.Base(dir) != ".cone" { + t.Errorf("expected parent dir to be .cone, got %s", filepath.Base(dir)) + } +} + +// contains is a helper for string containment checks +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(substr) == 0 || + (len(s) > 0 && len(substr) > 0 && findSubstring(s, substr))) +} + +func findSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/pkg/mcpclient/client.go b/pkg/mcpclient/client.go new file mode 100644 index 00000000..1ccd4710 --- /dev/null +++ b/pkg/mcpclient/client.go @@ -0,0 +1,267 @@ +package mcpclient + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// Client is an MCP client for connecting to C1's connector analysis service. +type Client struct { + // ServerURL is the URL of the MCP server (e.g., https://tenant.conductorone.com/api/v1alpha/cone/mcp) + ServerURL string + + // AuthToken is the authentication token from cone login. + AuthToken string + + // HTTPClient is the HTTP client to use. If nil, a default client is used. + HTTPClient *http.Client + + // Timeout is the request timeout. + Timeout time.Duration + + // ToolHandler handles tool callbacks from the server. + ToolHandler *ToolHandler + + // initialized tracks whether we've completed the MCP handshake. + initialized bool + + // requestID is a counter for JSON-RPC request IDs. + requestID int +} + +// NewClient creates a new MCP client. +func NewClient(serverURL, authToken string, toolHandler *ToolHandler) *Client { + return &Client{ + ServerURL: serverURL, + AuthToken: authToken, + HTTPClient: &http.Client{Timeout: 30 * time.Second}, + Timeout: 30 * time.Second, + ToolHandler: toolHandler, + } +} + +// jsonrpcRequest represents a JSON-RPC request. +type jsonrpcRequest struct { + JSONRPC string `json:"jsonrpc"` + ID int `json:"id"` + Method string `json:"method"` + Params interface{} `json:"params,omitempty"` +} + +// jsonrpcResponse represents a JSON-RPC response. +type jsonrpcResponse struct { + JSONRPC string `json:"jsonrpc"` + ID int `json:"id"` + Result json.RawMessage `json:"result,omitempty"` + Error *jsonrpcError `json:"error,omitempty"` +} + +type jsonrpcError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// Connect establishes a connection to the MCP server. +func (c *Client) Connect(ctx context.Context) error { + // Send initialize request + resp, err := c.sendRequest(ctx, "initialize", map[string]interface{}{ + "protocolVersion": "2024-11-05", + "clientInfo": map[string]string{ + "name": "cone", + "version": "0.1.0", + }, + "capabilities": map[string]interface{}{ + "tools": map[string]bool{"supported": true}, + }, + }) + if err != nil { + return fmt.Errorf("initialize failed: %w", err) + } + + // Parse initialize result + var initResult struct { + ProtocolVersion string `json:"protocolVersion"` + ServerInfo struct { + Name string `json:"name"` + Version string `json:"version"` + } `json:"serverInfo"` + } + if err := json.Unmarshal(resp.Result, &initResult); err != nil { + return fmt.Errorf("failed to parse initialize result: %w", err) + } + + c.initialized = true + return nil +} + +// Analyze starts a connector analysis session. +// It handles the full interaction loop: calling connector_analyze, +// processing tool callbacks, and returning when complete. +func (c *Client) Analyze(ctx context.Context, connectorPath string, mode string) (*AnalysisResult, error) { + if !c.initialized { + if err := c.Connect(ctx); err != nil { + return nil, err + } + } + + if mode == "" { + mode = "interactive" + } + + // Call connector_analyze tool + resp, err := c.sendRequest(ctx, "tools/call", map[string]interface{}{ + "name": "connector_analyze", + "arguments": map[string]interface{}{ + "connector_path": connectorPath, + "mode": mode, + }, + }) + if err != nil { + return nil, fmt.Errorf("connector_analyze failed: %w", err) + } + + // Process the response and any tool callbacks + return c.processAnalysisResponse(ctx, resp) +} + +// AnalysisResult contains the results of a connector analysis. +type AnalysisResult struct { + Status string `json:"status"` + Message string `json:"message"` + IssuesFound int `json:"issues_found"` + FilesScanned int `json:"files_scanned"` + Summary map[string]interface{} `json:"summary,omitempty"` +} + +// processAnalysisResponse handles the analysis response, including tool callback loops. +func (c *Client) processAnalysisResponse(ctx context.Context, resp *jsonrpcResponse) (*AnalysisResult, error) { + for { + var result struct { + Status string `json:"status"` + Message string `json:"message"` + ToolCall *struct { + Name string `json:"name"` + Arguments map[string]interface{} `json:"arguments"` + } `json:"tool_call,omitempty"` + Summary map[string]interface{} `json:"summary,omitempty"` + } + + if err := json.Unmarshal(resp.Result, &result); err != nil { + return nil, fmt.Errorf("failed to parse analysis response: %w", err) + } + + switch result.Status { + case "complete": + // Analysis complete + issuesFound := 0 + filesScanned := 0 + if result.Summary != nil { + if v, ok := result.Summary["issues_found"].(float64); ok { + issuesFound = int(v) + } + if v, ok := result.Summary["files_analyzed"].(float64); ok { + filesScanned = int(v) + } + } + return &AnalysisResult{ + Status: "complete", + Message: result.Message, + IssuesFound: issuesFound, + FilesScanned: filesScanned, + Summary: result.Summary, + }, nil + + case "tool_call": + if result.ToolCall == nil { + return nil, fmt.Errorf("tool_call status but no tool_call data") + } + + // Execute the tool locally + toolResult, err := c.ToolHandler.HandleToolCall(ctx, result.ToolCall.Name, result.ToolCall.Arguments) + if err != nil { + return nil, fmt.Errorf("tool %s failed: %w", result.ToolCall.Name, err) + } + + // Send the result back to the server + resp, err = c.sendRequest(ctx, "tool_result", map[string]interface{}{ + "tool": result.ToolCall.Name, + "result": toolResult, + }) + if err != nil { + return nil, fmt.Errorf("failed to send tool result: %w", err) + } + + // Continue the loop with the new response + continue + + case "error": + return &AnalysisResult{ + Status: "error", + Message: result.Message, + }, nil + + default: + return nil, fmt.Errorf("unexpected status: %s", result.Status) + } + } +} + +// sendRequest sends a JSON-RPC request to the server. +func (c *Client) sendRequest(ctx context.Context, method string, params interface{}) (*jsonrpcResponse, error) { + c.requestID++ + req := jsonrpcRequest{ + JSONRPC: "2.0", + ID: c.requestID, + Method: method, + Params: params, + } + + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.ServerURL, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + if c.AuthToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+c.AuthToken) + } + + httpResp, err := c.HTTPClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer httpResp.Body.Close() + + if httpResp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(httpResp.Body) + return nil, fmt.Errorf("server returned %d: %s", httpResp.StatusCode, string(bodyBytes)) + } + + var resp jsonrpcResponse + if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + if resp.Error != nil { + return nil, fmt.Errorf("server error %d: %s", resp.Error.Code, resp.Error.Message) + } + + return &resp, nil +} + +// Close closes the client connection. +func (c *Client) Close() error { + // HTTP client doesn't need explicit cleanup + c.initialized = false + return nil +} diff --git a/pkg/mcpclient/client_test.go b/pkg/mcpclient/client_test.go new file mode 100644 index 00000000..831cf7fa --- /dev/null +++ b/pkg/mcpclient/client_test.go @@ -0,0 +1,226 @@ +package mcpclient + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestClient_Connect(t *testing.T) { + t.Run("successful initialize", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req map[string]interface{} + json.NewDecoder(r.Body).Decode(&req) + + if req["method"] != "initialize" { + t.Errorf("expected initialize method, got %v", req["method"]) + } + + resp := map[string]interface{}{ + "jsonrpc": "2.0", + "id": req["id"], + "result": map[string]interface{}{ + "protocolVersion": "2024-11-05", + "serverInfo": map[string]string{ + "name": "test-server", + "version": "1.0", + }, + }, + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := &Client{ + ServerURL: server.URL, + HTTPClient: http.DefaultClient, + } + + err := client.Connect(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("handles server error", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req map[string]interface{} + json.NewDecoder(r.Body).Decode(&req) + + resp := map[string]interface{}{ + "jsonrpc": "2.0", + "id": req["id"], + "error": map[string]interface{}{ + "code": -32600, + "message": "Invalid Request", + }, + } + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + client := &Client{ + ServerURL: server.URL, + HTTPClient: http.DefaultClient, + } + + err := client.Connect(context.Background()) + if err == nil { + t.Error("expected error for server error response") + } + }) +} + +func TestClient_Analyze(t *testing.T) { + t.Run("handles tool_call response", func(t *testing.T) { + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req map[string]interface{} + json.NewDecoder(r.Body).Decode(&req) + + callCount++ + var resp map[string]interface{} + + switch req["method"] { + case "initialize": + resp = map[string]interface{}{ + "jsonrpc": "2.0", + "id": req["id"], + "result": map[string]interface{}{ + "protocolVersion": "2024-11-05", + }, + } + case "tools/call": + params := req["params"].(map[string]interface{}) + if params["name"] == "connector_analyze" { + resp = map[string]interface{}{ + "jsonrpc": "2.0", + "id": req["id"], + "result": map[string]interface{}{ + "content": []map[string]interface{}{ + { + "type": "text", + "text": `{"status":"tool_call","session_id":"test-session","tool_call":{"name":"read_files","arguments":{"paths":["go.mod"]}}}`, + }, + }, + }, + } + } else if params["name"] == "tool_result" { + resp = map[string]interface{}{ + "jsonrpc": "2.0", + "id": req["id"], + "result": map[string]interface{}{ + "content": []map[string]interface{}{ + { + "type": "text", + "text": `{"status":"complete","session_id":"test-session","message":"Done"}`, + }, + }, + }, + } + } + } + + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + // Create a mock tool handler that returns empty results + handler := &ToolHandler{ + ConnectorDir: "/tmp/test", + DryRun: true, + } + + client := &Client{ + ServerURL: server.URL, + HTTPClient: http.DefaultClient, + ToolHandler: handler, + } + + // This will fail because the tool handler can't actually read files, + // but it tests the client's ability to parse responses + _, err := client.Analyze(context.Background(), "/tmp/test", "full") + // We expect an error because read_files will fail on non-existent path + if err == nil { + // If no error, the flow completed somehow + t.Log("analyze completed without error") + } + }) +} + +func TestToolHandler_HandleToolCall(t *testing.T) { + handler := &ToolHandler{ + ConnectorDir: ".", + DryRun: true, + } + + t.Run("read_files with valid path", func(t *testing.T) { + result, err := handler.HandleToolCall(context.Background(), "read_files", map[string]interface{}{ + "patterns": []interface{}{"client_test.go"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.Success { + t.Errorf("expected success, got error: %s", result.Error) + } + }) + + t.Run("read_files with missing path", func(t *testing.T) { + result, err := handler.HandleToolCall(context.Background(), "read_files", map[string]interface{}{ + "patterns": []interface{}{"nonexistent_file_12345.go"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Should still succeed but with empty files (no matches) + if !result.Success { + t.Log("read_files reported failure for missing file (acceptable)") + } + }) + + t.Run("unknown tool returns error in result", func(t *testing.T) { + result, err := handler.HandleToolCall(context.Background(), "unknown_tool", map[string]interface{}{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Unknown tools return Success: false with an error message + if result.Success { + t.Error("expected Success=false for unknown tool") + } + if result.Error == "" { + t.Error("expected error message for unknown tool") + } + }) + + t.Run("write_file in dry-run mode", func(t *testing.T) { + result, err := handler.HandleToolCall(context.Background(), "write_file", map[string]interface{}{ + "path": "/tmp/test.txt", + "content": "test content", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !result.Success { + t.Error("expected success in dry-run mode") + } + // In dry-run, file should not actually be written + }) +} + +// Helper to create JSON request body +func jsonBody(method string, params interface{}) *bytes.Buffer { + req := map[string]interface{}{ + "jsonrpc": "2.0", + "id": 1, + "method": method, + } + if params != nil { + req["params"] = params + } + body, _ := json.Marshal(req) + return bytes.NewBuffer(body) +} diff --git a/pkg/mcpclient/integration_test.go b/pkg/mcpclient/integration_test.go new file mode 100644 index 00000000..f3d6d196 --- /dev/null +++ b/pkg/mcpclient/integration_test.go @@ -0,0 +1,212 @@ +package mcpclient + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/conductorone/cone/pkg/mcpclient/mock" +) + +// TestIntegration_FullAnalysisFlow runs a complete end-to-end test: +// 1. Creates temp connector directory with files +// 2. Starts mock MCP server +// 3. Connects cone client +// 4. Runs analysis with tool callbacks +// 5. Verifies completion +func TestIntegration_FullAnalysisFlow(t *testing.T) { + // Create a temporary connector directory with some files + tmpDir, err := os.MkdirTemp("", "connector-integration-test") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Create minimal connector files that the mock server will request + files := map[string]string{ + "go.mod": "module test/connector\n\ngo 1.21\n", + "connector.go": "package connector\n\ntype Connector struct{}\n", + "README.md": "# Test Connector\n", + } + for name, content := range files { + if err := os.WriteFile(filepath.Join(tmpDir, name), []byte(content), 0644); err != nil { + t.Fatalf("failed to create %s: %v", name, err) + } + } + + // Create a test scenario that only uses read_files (no user interaction) + readOnlyScenario := &mock.Scenario{ + Name: "read_only", + Description: "Only reads files, no user interaction", + ToolCalls: []mock.ToolCall{ + { + Name: "read_files", + Arguments: map[string]interface{}{ + "patterns": []string{"*.go", "*.md"}, + }, + }, + }, + } + + mockServer := mock.NewServer(readOnlyScenario) + ts := httptest.NewServer(http.HandlerFunc(mockServer.HandleMCP)) + defer ts.Close() + + // Create tool handler that works non-interactively + handler := &ToolHandler{ + ConnectorDir: tmpDir, + DryRun: true, + } + + client := &Client{ + ServerURL: ts.URL, + HTTPClient: http.DefaultClient, + ToolHandler: handler, + } + + ctx := context.Background() + + // Step 1: Connect (initialize) + if err := client.Connect(ctx); err != nil { + t.Fatalf("Connect failed: %v", err) + } + + // Step 2: Start analysis - this triggers tool callbacks + result, err := client.Analyze(ctx, tmpDir, "full") + if err != nil { + t.Fatalf("Analyze failed: %v", err) + } + + // Step 3: Verify we got a completion result + if result == nil { + t.Fatal("expected non-nil result") + } + + t.Logf("Analysis completed: %+v", result) + + // Step 4: Verify the mock server received the tool results + toolResults := mockServer.ToolResults() + if len(toolResults) != 1 { + t.Errorf("expected 1 tool result (read_files), got %d", len(toolResults)) + } +} + +// TestIntegration_ManualProtocolFlow tests the raw JSON-RPC protocol +// without going through the client abstraction. +func TestIntegration_ManualProtocolFlow(t *testing.T) { + scenario := mock.HappyPathScenario() + mockServer := mock.NewServer(scenario) + + ts := httptest.NewServer(http.HandlerFunc(mockServer.HandleMCP)) + defer ts.Close() + + httpClient := &http.Client{} + + // 1. Initialize + resp := sendJSONRPC(t, httpClient, ts.URL, "initialize", map[string]interface{}{ + "protocolVersion": "2024-11-05", + "clientInfo": map[string]string{"name": "integration-test", "version": "1.0"}, + }) + assertNoRPCError(t, resp) + t.Log("Initialize: OK") + + // 2. List tools + resp = sendJSONRPC(t, httpClient, ts.URL, "tools/list", nil) + assertNoRPCError(t, resp) + t.Log("tools/list: OK") + + // 3. Call connector_analyze + resp = sendJSONRPC(t, httpClient, ts.URL, "tools/call", map[string]interface{}{ + "name": "connector_analyze", + "arguments": map[string]interface{}{ + "connector_path": "/test/connector", + }, + }) + assertNoRPCError(t, resp) + + // Verify we got a tool_call response + result := resp["result"].(map[string]interface{}) + if result["status"] != "tool_call" { + t.Fatalf("expected status 'tool_call', got %v", result["status"]) + } + toolCall := result["tool_call"].(map[string]interface{}) + if toolCall["name"] != "read_files" { + t.Fatalf("expected first tool to be 'read_files', got %v", toolCall["name"]) + } + t.Log("tools/call connector_analyze: OK, got read_files callback") + + // 4. Send tool results for each expected callback + for i, tc := range scenario.ToolCalls { + resp = sendJSONRPC(t, httpClient, ts.URL, "tool_result", map[string]interface{}{ + "tool": tc.Name, + "result": map[string]interface{}{ + "success": true, + "data": map[string]interface{}{"mock": "data"}, + }, + }) + assertNoRPCError(t, resp) + + result := resp["result"].(map[string]interface{}) + status := result["status"].(string) + t.Logf("tool_result %d (%s): status=%s", i+1, tc.Name, status) + + if i == len(scenario.ToolCalls)-1 { + // Last one should be complete + if status != "complete" { + t.Errorf("expected final status 'complete', got %s", status) + } + } else { + // Others should be tool_call + if status != "tool_call" { + t.Errorf("expected status 'tool_call', got %s", status) + } + } + } + + // 5. Verify all results were recorded + results := mockServer.ToolResults() + if len(results) != len(scenario.ToolCalls) { + t.Errorf("expected %d tool results, got %d", len(scenario.ToolCalls), len(results)) + } + + t.Log("Full protocol flow completed successfully") +} + +func sendJSONRPC(t *testing.T, client *http.Client, url, method string, params interface{}) map[string]interface{} { + t.Helper() + + req := map[string]interface{}{ + "jsonrpc": "2.0", + "id": 1, + "method": method, + } + if params != nil { + req["params"] = params + } + + body, _ := json.Marshal(req) + resp, err := client.Post(url, "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + return result +} + +func assertNoRPCError(t *testing.T, resp map[string]interface{}) { + t.Helper() + if errObj, ok := resp["error"]; ok && errObj != nil { + t.Fatalf("unexpected JSON-RPC error: %v", errObj) + } +} diff --git a/pkg/mcpclient/mock/server.go b/pkg/mcpclient/mock/server.go new file mode 100644 index 00000000..857f041b --- /dev/null +++ b/pkg/mcpclient/mock/server.go @@ -0,0 +1,318 @@ +// Package mock provides a mock MCP server for testing cone's MCP client +// before the C1 MCP server is ready. +// +// The mock server simulates the C1 connector analysis workflow: +// 1. Accept connector_analyze call +// 2. Return tool callbacks (read_files, ask_user, edit_file) +// 3. Process tool results +// 4. Complete the session +package mock + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "sync" +) + +// Scenario defines a test scenario with canned tool calls and expected responses. +type Scenario struct { + Name string + Description string + ToolCalls []ToolCall +} + +// ToolCall represents a tool call the mock server will make to the client. +type ToolCall struct { + Name string `json:"name"` + Arguments map[string]interface{} `json:"arguments"` + // ExpectedResult is used for validation in tests + ExpectedResult map[string]interface{} `json:"-"` +} + +// Server is a mock MCP server for testing. +type Server struct { + scenario *Scenario + currentStep int + mu sync.Mutex + addr string + server *http.Server + toolResults []map[string]interface{} + sessionID string + initialized bool +} + +// NewServer creates a new mock MCP server with the given scenario. +func NewServer(scenario *Scenario) *Server { + return &Server{ + scenario: scenario, + toolResults: make([]map[string]interface{}, 0), + } +} + +// Start starts the mock server on the given address. +func (s *Server) Start(addr string) error { + s.addr = addr + mux := http.NewServeMux() + mux.HandleFunc("/", s.HandleMCP) + + s.server = &http.Server{ + Addr: addr, + Handler: mux, + } + + go func() { + if err := s.server.ListenAndServe(); err != http.ErrServerClosed { + fmt.Printf("mock server error: %v\n", err) + } + }() + + return nil +} + +// Stop stops the mock server. +func (s *Server) Stop(ctx context.Context) error { + if s.server != nil { + return s.server.Shutdown(ctx) + } + return nil +} + +// Addr returns the server address. +func (s *Server) Addr() string { + return s.addr +} + +// ToolResults returns the results received from tool calls. +func (s *Server) ToolResults() []map[string]interface{} { + s.mu.Lock() + defer s.mu.Unlock() + return s.toolResults +} + +// HandleMCP handles MCP protocol messages. Exported for use in tests. +func (s *Server) HandleMCP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + JSONRPC string `json:"jsonrpc"` + ID interface{} `json:"id"` + Method string `json:"method"` + Params map[string]interface{} `json:"params"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + s.sendError(w, nil, -32700, "parse error") + return + } + + switch req.Method { + case "initialize": + s.handleInitialize(w, req.ID, req.Params) + case "tools/list": + s.handleToolsList(w, req.ID) + case "tools/call": + s.handleToolsCall(w, req.ID, req.Params) + case "tool_result": + s.handleToolResult(w, req.ID, req.Params) + default: + s.sendError(w, req.ID, -32601, fmt.Sprintf("method not found: %s", req.Method)) + } +} + +func (s *Server) handleInitialize(w http.ResponseWriter, id interface{}, params map[string]interface{}) { + s.mu.Lock() + s.initialized = true + s.sessionID = fmt.Sprintf("mock-session-%d", s.currentStep) + s.mu.Unlock() + + s.sendResult(w, id, map[string]interface{}{ + "protocolVersion": "2024-11-05", + "serverInfo": map[string]string{ + "name": "c1-mock-mcp", + "version": "0.1.0-test", + }, + "capabilities": map[string]interface{}{ + "tools": map[string]bool{"supported": true}, + }, + }) +} + +func (s *Server) handleToolsList(w http.ResponseWriter, id interface{}) { + tools := []map[string]interface{}{ + { + "name": "connector_analyze", + "description": "Analyze a connector for issues and improvements", + "inputSchema": map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "connector_path": map[string]string{"type": "string"}, + "mode": map[string]string{"type": "string"}, + }, + "required": []string{"connector_path"}, + }, + }, + } + + s.sendResult(w, id, map[string]interface{}{ + "tools": tools, + }) +} + +func (s *Server) handleToolsCall(w http.ResponseWriter, id interface{}, params map[string]interface{}) { + name, _ := params["name"].(string) + + if name != "connector_analyze" { + s.sendError(w, id, -32602, fmt.Sprintf("unknown tool: %s", name)) + return + } + + s.mu.Lock() + s.currentStep = 0 + s.mu.Unlock() + + // Return the first tool callback + s.sendNextToolCallback(w, id) +} + +func (s *Server) handleToolResult(w http.ResponseWriter, id interface{}, params map[string]interface{}) { + s.mu.Lock() + s.toolResults = append(s.toolResults, params) + s.currentStep++ + step := s.currentStep + s.mu.Unlock() + + // Check if we have more tool calls + if step < len(s.scenario.ToolCalls) { + s.sendNextToolCallback(w, id) + return + } + + // Analysis complete + s.sendResult(w, id, map[string]interface{}{ + "status": "complete", + "message": "Analysis finished", + "summary": map[string]interface{}{ + "issues_found": len(s.scenario.ToolCalls), + "files_analyzed": len(s.toolResults), + }, + }) +} + +func (s *Server) sendNextToolCallback(w http.ResponseWriter, id interface{}) { + s.mu.Lock() + if s.currentStep >= len(s.scenario.ToolCalls) { + s.mu.Unlock() + s.sendResult(w, id, map[string]interface{}{ + "status": "complete", + "message": "No more tool calls", + }) + return + } + toolCall := s.scenario.ToolCalls[s.currentStep] + s.mu.Unlock() + + s.sendResult(w, id, map[string]interface{}{ + "status": "tool_call", + "tool_call": toolCall, + }) +} + +func (s *Server) sendResult(w http.ResponseWriter, id interface{}, result interface{}) { + resp := map[string]interface{}{ + "jsonrpc": "2.0", + "id": id, + "result": result, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +func (s *Server) sendError(w http.ResponseWriter, id interface{}, code int, message string) { + resp := map[string]interface{}{ + "jsonrpc": "2.0", + "id": id, + "error": map[string]interface{}{ + "code": code, + "message": message, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +// Predefined test scenarios + +// HappyPathScenario returns a scenario that reads files and suggests an edit. +func HappyPathScenario() *Scenario { + return &Scenario{ + Name: "happy_path", + Description: "Read connector files, suggest an edit", + ToolCalls: []ToolCall{ + { + Name: "read_files", + Arguments: map[string]interface{}{ + "patterns": []string{"*.go", "*.yaml"}, + }, + }, + { + Name: "ask_user", + Arguments: map[string]interface{}{ + "question": "Found a missing error check. Should I fix it?", + "type": "confirm", + }, + }, + { + Name: "edit_file", + Arguments: map[string]interface{}{ + "path": "connector.go", + "old": "result, _ := client.Get()", + "new": "result, err := client.Get()\nif err != nil {\n return nil, err\n}", + }, + }, + }, + } +} + +// UserDeclinesScenario returns a scenario where the user declines an edit. +func UserDeclinesScenario() *Scenario { + return &Scenario{ + Name: "user_declines", + Description: "User declines a suggested edit", + ToolCalls: []ToolCall{ + { + Name: "read_files", + Arguments: map[string]interface{}{ + "patterns": []string{"*.go"}, + }, + }, + { + Name: "ask_user", + Arguments: map[string]interface{}{ + "question": "Should I refactor this function?", + "type": "confirm", + }, + }, + }, + } +} + +// InvalidToolCallScenario returns a scenario with an invalid tool call. +func InvalidToolCallScenario() *Scenario { + return &Scenario{ + Name: "invalid_tool", + Description: "Server sends an unknown tool call", + ToolCalls: []ToolCall{ + { + Name: "nonexistent_tool", + Arguments: map[string]interface{}{ + "foo": "bar", + }, + }, + }, + } +} diff --git a/pkg/mcpclient/mock/server_test.go b/pkg/mcpclient/mock/server_test.go new file mode 100644 index 00000000..2356272a --- /dev/null +++ b/pkg/mcpclient/mock/server_test.go @@ -0,0 +1,142 @@ +package mock + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestMockServer_HappyPath(t *testing.T) { + scenario := HappyPathScenario() + server := NewServer(scenario) + + // Create a test server + ts := httptest.NewServer(http.HandlerFunc(server.HandleMCP)) + defer ts.Close() + + // Test initialize + resp := doRequest(t, ts.URL, "initialize", map[string]interface{}{ + "protocolVersion": "2024-11-05", + "clientInfo": map[string]string{"name": "test", "version": "1.0"}, + }) + assertNoError(t, resp) + + // Test tools/list + resp = doRequest(t, ts.URL, "tools/list", nil) + assertNoError(t, resp) + + var listResult struct { + Tools []map[string]interface{} `json:"tools"` + } + listBytes, _ := json.Marshal(resp["result"]) + if err := json.Unmarshal(listBytes, &listResult); err != nil { + t.Fatalf("failed to parse tools/list result: %v", err) + } + if len(listResult.Tools) == 0 { + t.Error("expected at least one tool") + } + + // Test tools/call for connector_analyze + resp = doRequest(t, ts.URL, "tools/call", map[string]interface{}{ + "name": "connector_analyze", + "arguments": map[string]interface{}{ + "connector_path": "/test/connector", + }, + }) + assertNoError(t, resp) + + // Should get first tool callback (read_files) + var callResult struct { + Status string `json:"status"` + ToolCall struct { + Name string `json:"name"` + Arguments map[string]interface{} `json:"arguments"` + } `json:"tool_call"` + } + resultBytes, _ := json.Marshal(resp["result"]) + if err := json.Unmarshal(resultBytes, &callResult); err != nil { + t.Fatalf("failed to parse tool call result: %v", err) + } + + if callResult.Status != "tool_call" { + t.Errorf("expected status 'tool_call', got '%s'", callResult.Status) + } + if callResult.ToolCall.Name != "read_files" { + t.Errorf("expected first tool call to be 'read_files', got '%s'", callResult.ToolCall.Name) + } +} + +func TestMockServer_SessionTracking(t *testing.T) { + scenario := HappyPathScenario() + server := NewServer(scenario) + + ts := httptest.NewServer(http.HandlerFunc(server.HandleMCP)) + defer ts.Close() + + // Initialize + doRequest(t, ts.URL, "initialize", map[string]interface{}{ + "protocolVersion": "2024-11-05", + "clientInfo": map[string]string{"name": "test", "version": "1.0"}, + }) + + // Start analysis + doRequest(t, ts.URL, "tools/call", map[string]interface{}{ + "name": "connector_analyze", + "arguments": map[string]interface{}{ + "connector_path": "/test", + }, + }) + + // Send tool results + for i := 0; i < len(scenario.ToolCalls); i++ { + doRequest(t, ts.URL, "tool_result", map[string]interface{}{ + "tool": scenario.ToolCalls[i].Name, + "result": map[string]interface{}{ + "success": true, + }, + }) + } + + // Verify all results were recorded + results := server.ToolResults() + if len(results) != len(scenario.ToolCalls) { + t.Errorf("expected %d tool results, got %d", len(scenario.ToolCalls), len(results)) + } +} + +func doRequest(t *testing.T, url, method string, params interface{}) map[string]interface{} { + t.Helper() + + req := map[string]interface{}{ + "jsonrpc": "2.0", + "id": 1, + "method": method, + } + if params != nil { + req["params"] = params + } + + body, _ := json.Marshal(req) + resp, err := http.Post(url, "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + var result map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + return result +} + +func assertNoError(t *testing.T, resp map[string]interface{}) { + t.Helper() + if errObj, ok := resp["error"]; ok && errObj != nil { + t.Fatalf("unexpected error: %v", errObj) + } +} + diff --git a/pkg/mcpclient/tools.go b/pkg/mcpclient/tools.go new file mode 100644 index 00000000..8b106667 --- /dev/null +++ b/pkg/mcpclient/tools.go @@ -0,0 +1,368 @@ +// Package mcpclient provides an MCP client for connecting to C1's connector analysis service. +package mcpclient + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/conductorone/cone/pkg/prompt" +) + +// ToolHandler handles tool callbacks from the MCP server. +type ToolHandler struct { + // ConnectorDir is the root directory of the connector being analyzed. + // All file operations are restricted to this directory. + ConnectorDir string + + // DryRun prevents actual file modifications when true. + DryRun bool + + // Verbose enables detailed output. + Verbose bool +} + +// NewToolHandler creates a new tool handler for the given connector directory. +func NewToolHandler(connectorDir string) *ToolHandler { + return &ToolHandler{ + ConnectorDir: connectorDir, + } +} + +// ToolResult is the result of executing a tool. +type ToolResult struct { + Success bool `json:"success"` + Data map[string]interface{} `json:"data,omitempty"` + Error string `json:"error,omitempty"` +} + +// HandleToolCall dispatches a tool call to the appropriate handler. +func (h *ToolHandler) HandleToolCall(ctx context.Context, name string, args map[string]interface{}) (*ToolResult, error) { + switch name { + case "read_files": + return h.handleReadFiles(ctx, args) + case "ask_user": + return h.handleAskUser(ctx, args) + case "edit_file": + return h.handleEditFile(ctx, args) + case "write_file": + return h.handleWriteFile(ctx, args) + case "show_diff": + return h.handleShowDiff(ctx, args) + case "confirm": + return h.handleConfirm(ctx, args) + default: + return &ToolResult{ + Success: false, + Error: fmt.Sprintf("unknown tool: %s", name), + }, nil + } +} + +// handleReadFiles reads files matching the given glob patterns. +// Security: Only reads files within ConnectorDir. +func (h *ToolHandler) handleReadFiles(ctx context.Context, args map[string]interface{}) (*ToolResult, error) { + patternsRaw, ok := args["patterns"] + if !ok { + return &ToolResult{Success: false, Error: "missing 'patterns' argument"}, nil + } + + var patterns []string + switch p := patternsRaw.(type) { + case []string: + patterns = p + case []interface{}: + for _, v := range p { + if s, ok := v.(string); ok { + patterns = append(patterns, s) + } + } + default: + return &ToolResult{Success: false, Error: "patterns must be an array of strings"}, nil + } + + files := make([]map[string]string, 0) + + for _, pattern := range patterns { + // Security: Resolve pattern relative to connector directory + fullPattern := filepath.Join(h.ConnectorDir, pattern) + + matches, err := filepath.Glob(fullPattern) + if err != nil { + continue // Skip invalid patterns + } + + for _, match := range matches { + // Security: Verify file is within connector directory + relPath, err := filepath.Rel(h.ConnectorDir, match) + if err != nil || strings.HasPrefix(relPath, "..") { + continue // Skip files outside connector dir + } + + info, err := os.Stat(match) + if err != nil || info.IsDir() { + continue // Skip directories and inaccessible files + } + + content, err := os.ReadFile(match) + if err != nil { + continue // Skip unreadable files + } + + files = append(files, map[string]string{ + "path": relPath, + "content": string(content), + }) + } + } + + return &ToolResult{ + Success: true, + Data: map[string]interface{}{ + "files": files, + }, + }, nil +} + +// handleAskUser prompts the user for input. +func (h *ToolHandler) handleAskUser(ctx context.Context, args map[string]interface{}) (*ToolResult, error) { + question, _ := args["question"].(string) + questionType, _ := args["type"].(string) + + if question == "" { + return &ToolResult{Success: false, Error: "missing 'question' argument"}, nil + } + + switch questionType { + case "confirm": + answer, err := prompt.Confirm(question) + if err != nil { + return &ToolResult{Success: false, Error: err.Error()}, nil + } + return &ToolResult{ + Success: true, + Data: map[string]interface{}{"answer": answer}, + }, nil + + case "text": + answer, err := prompt.Input(question + ": ") + if err != nil { + return &ToolResult{Success: false, Error: err.Error()}, nil + } + return &ToolResult{ + Success: true, + Data: map[string]interface{}{"answer": answer}, + }, nil + + case "select": + optionsRaw, _ := args["options"].([]interface{}) + var options []string + for _, o := range optionsRaw { + if s, ok := o.(string); ok { + options = append(options, s) + } + } + if len(options) == 0 { + return &ToolResult{Success: false, Error: "select requires options"}, nil + } + + idx, err := prompt.SelectString(question, options) + if err != nil { + return &ToolResult{Success: false, Error: err.Error()}, nil + } + return &ToolResult{ + Success: true, + Data: map[string]interface{}{ + "answer": options[idx], + "index": idx, + }, + }, nil + + default: + // Default to text input + answer, err := prompt.Input(question + ": ") + if err != nil { + return &ToolResult{Success: false, Error: err.Error()}, nil + } + return &ToolResult{ + Success: true, + Data: map[string]interface{}{"answer": answer}, + }, nil + } +} + +// handleEditFile applies an edit to a file. +// Security: Only edits files within ConnectorDir. +func (h *ToolHandler) handleEditFile(ctx context.Context, args map[string]interface{}) (*ToolResult, error) { + path, _ := args["path"].(string) + old, _ := args["old"].(string) + new, _ := args["new"].(string) + + if path == "" || old == "" { + return &ToolResult{Success: false, Error: "missing required arguments (path, old)"}, nil + } + + // Security: Resolve path relative to connector directory + fullPath := filepath.Join(h.ConnectorDir, path) + relPath, err := filepath.Rel(h.ConnectorDir, fullPath) + if err != nil || strings.HasPrefix(relPath, "..") { + return &ToolResult{Success: false, Error: "path must be within connector directory"}, nil + } + + // Read current content + content, err := os.ReadFile(fullPath) + if err != nil { + return &ToolResult{Success: false, Error: fmt.Sprintf("failed to read file: %v", err)}, nil + } + + // Check if old string exists + if !strings.Contains(string(content), old) { + return &ToolResult{Success: false, Error: "old string not found in file"}, nil + } + + // Show diff and ask for confirmation + fmt.Printf("\n--- %s (before)\n", path) + fmt.Printf("+++ %s (after)\n", path) + fmt.Printf("@@ edit @@\n") + fmt.Printf("-%s\n", strings.ReplaceAll(old, "\n", "\n-")) + fmt.Printf("+%s\n", strings.ReplaceAll(new, "\n", "\n+")) + fmt.Println() + + if h.DryRun { + return &ToolResult{ + Success: true, + Data: map[string]interface{}{"applied": false, "reason": "dry run"}, + }, nil + } + + accepted, err := prompt.Confirm("Apply this change?") + if err != nil { + return &ToolResult{Success: false, Error: err.Error()}, nil + } + + if !accepted { + return &ToolResult{ + Success: true, + Data: map[string]interface{}{"applied": false, "reason": "user declined"}, + }, nil + } + + // Apply the edit + newContent := strings.Replace(string(content), old, new, 1) + if err := os.WriteFile(fullPath, []byte(newContent), 0644); err != nil { + return &ToolResult{Success: false, Error: fmt.Sprintf("failed to write file: %v", err)}, nil + } + + return &ToolResult{ + Success: true, + Data: map[string]interface{}{"applied": true}, + }, nil +} + +// handleWriteFile writes a new file. +// Security: Only writes files within ConnectorDir. +func (h *ToolHandler) handleWriteFile(ctx context.Context, args map[string]interface{}) (*ToolResult, error) { + path, _ := args["path"].(string) + content, _ := args["content"].(string) + + if path == "" { + return &ToolResult{Success: false, Error: "missing 'path' argument"}, nil + } + + // Security: Resolve path relative to connector directory + fullPath := filepath.Join(h.ConnectorDir, path) + relPath, err := filepath.Rel(h.ConnectorDir, fullPath) + if err != nil || strings.HasPrefix(relPath, "..") { + return &ToolResult{Success: false, Error: "path must be within connector directory"}, nil + } + + // Check if file exists + if _, err := os.Stat(fullPath); err == nil { + // File exists, ask for confirmation + overwrite, err := prompt.Confirm(fmt.Sprintf("File %s exists. Overwrite?", path)) + if err != nil { + return &ToolResult{Success: false, Error: err.Error()}, nil + } + if !overwrite { + return &ToolResult{ + Success: true, + Data: map[string]interface{}{"written": false, "reason": "user declined overwrite"}, + }, nil + } + } + + if h.DryRun { + return &ToolResult{ + Success: true, + Data: map[string]interface{}{"written": false, "reason": "dry run"}, + }, nil + } + + // Ensure parent directory exists + if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { + return &ToolResult{Success: false, Error: fmt.Sprintf("failed to create directory: %v", err)}, nil + } + + // Write the file + if err := os.WriteFile(fullPath, []byte(content), 0644); err != nil { + return &ToolResult{Success: false, Error: fmt.Sprintf("failed to write file: %v", err)}, nil + } + + return &ToolResult{ + Success: true, + Data: map[string]interface{}{"written": true}, + }, nil +} + +// handleShowDiff displays a diff for review. +func (h *ToolHandler) handleShowDiff(ctx context.Context, args map[string]interface{}) (*ToolResult, error) { + path, _ := args["path"].(string) + old, _ := args["old"].(string) + new, _ := args["new"].(string) + + fmt.Printf("\n--- %s (before)\n", path) + fmt.Printf("+++ %s (after)\n", path) + fmt.Printf("@@ diff @@\n") + + // Simple line-by-line diff display + oldLines := strings.Split(old, "\n") + newLines := strings.Split(new, "\n") + + for _, line := range oldLines { + fmt.Printf("-%s\n", line) + } + for _, line := range newLines { + fmt.Printf("+%s\n", line) + } + fmt.Println() + + accepted, err := prompt.Confirm("Accept this change?") + if err != nil { + return &ToolResult{Success: false, Error: err.Error()}, nil + } + + return &ToolResult{ + Success: true, + Data: map[string]interface{}{"accepted": accepted}, + }, nil +} + +// handleConfirm asks for a simple yes/no confirmation. +func (h *ToolHandler) handleConfirm(ctx context.Context, args map[string]interface{}) (*ToolResult, error) { + message, _ := args["message"].(string) + if message == "" { + message = "Continue?" + } + + confirmed, err := prompt.Confirm(message) + if err != nil { + return &ToolResult{Success: false, Error: err.Error()}, nil + } + + return &ToolResult{ + Success: true, + Data: map[string]interface{}{"confirmed": confirmed}, + }, nil +} diff --git a/pkg/prompt/prompt.go b/pkg/prompt/prompt.go new file mode 100644 index 00000000..5fda3b3f --- /dev/null +++ b/pkg/prompt/prompt.go @@ -0,0 +1,328 @@ +// Package prompt provides simple interactive prompts for CLI applications. +// It uses basic fmt.Print + bufio.Scanner patterns (NOT Bubbletea). +// All prompts fail gracefully with an error when stdin is not a terminal. +package prompt + +import ( + "bufio" + "errors" + "fmt" + "os" + "strconv" + "strings" + + "golang.org/x/term" +) + +// ErrNotInteractive is returned when prompts are called in non-interactive mode. +var ErrNotInteractive = errors.New("prompt: stdin is not an interactive terminal") + +// ErrNoOptions is returned when Select is called with an empty options slice. +var ErrNoOptions = errors.New("prompt: no options provided") + +// ErrCancelled is returned when the user cancels a prompt (e.g., Ctrl+C). +var ErrCancelled = errors.New("prompt: cancelled by user") + +// IsInteractive returns true if stdin is an interactive terminal. +func IsInteractive() bool { + return term.IsTerminal(int(os.Stdin.Fd())) +} + +// requireInteractive returns an error if stdin is not interactive. +func requireInteractive() error { + if !IsInteractive() { + return ErrNotInteractive + } + return nil +} + +// Confirm prompts the user with a yes/no question. +// Returns true for yes, false for no. +func Confirm(question string) (bool, error) { + if err := requireInteractive(); err != nil { + return false, err + } + + reader := bufio.NewReader(os.Stdin) + + for { + fmt.Printf("%s [y/n]: ", question) + input, err := reader.ReadString('\n') + if err != nil { + return false, fmt.Errorf("failed to read input: %w", err) + } + + input = strings.TrimSpace(strings.ToLower(input)) + switch input { + case "y", "yes": + return true, nil + case "n", "no": + return false, nil + default: + fmt.Println("Please enter 'y' or 'n'.") + } + } +} + +// ConfirmWithDefault prompts the user with a yes/no question with a default. +// Empty input returns the default value. +func ConfirmWithDefault(question string, defaultYes bool) (bool, error) { + if err := requireInteractive(); err != nil { + return false, err + } + + reader := bufio.NewReader(os.Stdin) + prompt := "[y/N]" + if defaultYes { + prompt = "[Y/n]" + } + + for { + fmt.Printf("%s %s: ", question, prompt) + input, err := reader.ReadString('\n') + if err != nil { + return false, fmt.Errorf("failed to read input: %w", err) + } + + input = strings.TrimSpace(strings.ToLower(input)) + switch input { + case "": + return defaultYes, nil + case "y", "yes": + return true, nil + case "n", "no": + return false, nil + default: + fmt.Println("Please enter 'y' or 'n'.") + } + } +} + +// Input prompts the user for a single line of text input. +func Input(prompt string) (string, error) { + if err := requireInteractive(); err != nil { + return "", err + } + + reader := bufio.NewReader(os.Stdin) + fmt.Print(prompt) + input, err := reader.ReadString('\n') + if err != nil { + return "", fmt.Errorf("failed to read input: %w", err) + } + + return strings.TrimSpace(input), nil +} + +// InputWithDefault prompts for text input with a default value. +func InputWithDefault(promptText, defaultValue string) (string, error) { + if err := requireInteractive(); err != nil { + return "", err + } + + reader := bufio.NewReader(os.Stdin) + if defaultValue != "" { + fmt.Printf("%s [%s]: ", promptText, defaultValue) + } else { + fmt.Printf("%s: ", promptText) + } + + input, err := reader.ReadString('\n') + if err != nil { + return "", fmt.Errorf("failed to read input: %w", err) + } + + input = strings.TrimSpace(input) + if input == "" { + return defaultValue, nil + } + return input, nil +} + +// Option represents a selectable option. +type Option struct { + Label string + Description string +} + +// Select prompts the user to select from a list of options. +// Returns the index of the selected option. +func Select(question string, options []Option) (int, error) { + if err := requireInteractive(); err != nil { + return -1, err + } + + if len(options) == 0 { + return -1, ErrNoOptions + } + + reader := bufio.NewReader(os.Stdin) + + // Display the question and options + fmt.Println(question) + fmt.Println() + for i, opt := range options { + if opt.Description != "" { + fmt.Printf(" %d) %s\n %s\n", i+1, opt.Label, opt.Description) + } else { + fmt.Printf(" %d) %s\n", i+1, opt.Label) + } + } + fmt.Println() + + for { + fmt.Printf("Enter selection (1-%d): ", len(options)) + input, err := reader.ReadString('\n') + if err != nil { + return -1, fmt.Errorf("failed to read input: %w", err) + } + + input = strings.TrimSpace(input) + choice, err := strconv.Atoi(input) + if err != nil || choice < 1 || choice > len(options) { + fmt.Printf("Please enter a number between 1 and %d.\n", len(options)) + continue + } + + return choice - 1, nil // Return 0-indexed + } +} + +// SelectString is a convenience wrapper that takes string options. +func SelectString(question string, options []string) (int, error) { + opts := make([]Option, len(options)) + for i, s := range options { + opts[i] = Option{Label: s} + } + return Select(question, opts) +} + +// MultiSelect prompts the user to select multiple options. +// Returns the indices of selected options. +func MultiSelect(question string, options []Option) ([]int, error) { + if err := requireInteractive(); err != nil { + return nil, err + } + + if len(options) == 0 { + return nil, ErrNoOptions + } + + reader := bufio.NewReader(os.Stdin) + + // Display the question and options + fmt.Println(question) + fmt.Println("(Enter comma-separated numbers, or 'all' for all, 'none' for none)") + fmt.Println() + for i, opt := range options { + if opt.Description != "" { + fmt.Printf(" %d) %s\n %s\n", i+1, opt.Label, opt.Description) + } else { + fmt.Printf(" %d) %s\n", i+1, opt.Label) + } + } + fmt.Println() + + for { + fmt.Printf("Enter selections: ") + input, err := reader.ReadString('\n') + if err != nil { + return nil, fmt.Errorf("failed to read input: %w", err) + } + + input = strings.TrimSpace(strings.ToLower(input)) + + if input == "all" { + indices := make([]int, len(options)) + for i := range options { + indices[i] = i + } + return indices, nil + } + + if input == "none" || input == "" { + return []int{}, nil + } + + // Parse comma-separated numbers + parts := strings.Split(input, ",") + indices := make([]int, 0, len(parts)) + valid := true + + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + choice, err := strconv.Atoi(p) + if err != nil || choice < 1 || choice > len(options) { + fmt.Printf("Invalid selection: %s. Please enter numbers between 1 and %d.\n", p, len(options)) + valid = false + break + } + indices = append(indices, choice-1) // 0-indexed + } + + if valid { + return indices, nil + } + } +} + +// DisplayBox prints text in a simple box. +// Used for consent dialogs and other important messages. +func DisplayBox(title, content string) { + width := 70 + + // Top border + fmt.Println("+" + strings.Repeat("-", width-2) + "+") + + // Title + if title != "" { + padding := (width - 2 - len(title)) / 2 + fmt.Printf("|%s%s%s|\n", strings.Repeat(" ", padding), title, strings.Repeat(" ", width-2-padding-len(title))) + fmt.Println("+" + strings.Repeat("-", width-2) + "+") + } + + // Content - word wrap + lines := wrapText(content, width-4) + for _, line := range lines { + padding := width - 2 - len(line) + fmt.Printf("| %s%s|\n", line, strings.Repeat(" ", padding-1)) + } + + // Bottom border + fmt.Println("+" + strings.Repeat("-", width-2) + "+") +} + +// wrapText wraps text to the given width. +func wrapText(text string, width int) []string { + var lines []string + paragraphs := strings.Split(text, "\n") + + for _, para := range paragraphs { + if para == "" { + lines = append(lines, "") + continue + } + + words := strings.Fields(para) + if len(words) == 0 { + lines = append(lines, "") + continue + } + + line := words[0] + for _, word := range words[1:] { + if len(line)+1+len(word) <= width { + line += " " + word + } else { + lines = append(lines, line) + line = word + } + } + lines = append(lines, line) + } + + return lines +} diff --git a/pkg/prompt/prompt_test.go b/pkg/prompt/prompt_test.go new file mode 100644 index 00000000..9f4bc178 --- /dev/null +++ b/pkg/prompt/prompt_test.go @@ -0,0 +1,385 @@ +package prompt + +import ( + "strings" + "testing" +) + +func TestIsInteractive(t *testing.T) { + // In tests, stdin is typically not a terminal + // This test just verifies the function doesn't panic + _ = IsInteractive() +} + +func TestRequireInteractive_InTests(t *testing.T) { + // In test environment, stdin is not a terminal + err := requireInteractive() + // Should return error in non-interactive context + if IsInteractive() { + if err != nil { + t.Errorf("expected no error in interactive mode, got %v", err) + } + } else { + if err != ErrNotInteractive { + t.Errorf("expected ErrNotInteractive, got %v", err) + } + } +} + +func TestConfirm_NonInteractive(t *testing.T) { + if IsInteractive() { + t.Skip("test requires non-interactive environment") + } + + result, err := Confirm("Test question?") + if result { + t.Error("expected false result") + } + if err != ErrNotInteractive { + t.Errorf("expected ErrNotInteractive, got %v", err) + } +} + +func TestConfirmWithDefault_NonInteractive(t *testing.T) { + if IsInteractive() { + t.Skip("test requires non-interactive environment") + } + + result, err := ConfirmWithDefault("Test question?", true) + if result { + t.Error("expected false result") + } + if err != ErrNotInteractive { + t.Errorf("expected ErrNotInteractive, got %v", err) + } +} + +func TestInput_NonInteractive(t *testing.T) { + if IsInteractive() { + t.Skip("test requires non-interactive environment") + } + + result, err := Input("Enter value: ") + if result != "" { + t.Errorf("expected empty result, got %s", result) + } + if err != ErrNotInteractive { + t.Errorf("expected ErrNotInteractive, got %v", err) + } +} + +func TestInputWithDefault_NonInteractive(t *testing.T) { + if IsInteractive() { + t.Skip("test requires non-interactive environment") + } + + result, err := InputWithDefault("Enter value", "default") + if result != "" { + t.Errorf("expected empty result, got %s", result) + } + if err != ErrNotInteractive { + t.Errorf("expected ErrNotInteractive, got %v", err) + } +} + +func TestSelect_NonInteractive(t *testing.T) { + if IsInteractive() { + t.Skip("test requires non-interactive environment") + } + + options := []Option{ + {Label: "Option 1"}, + {Label: "Option 2"}, + } + + result, err := Select("Choose:", options) + if result != -1 { + t.Errorf("expected -1, got %d", result) + } + if err != ErrNotInteractive { + t.Errorf("expected ErrNotInteractive, got %v", err) + } +} + +func TestSelect_NoOptions(t *testing.T) { + // Even in interactive mode, empty options should fail + result, err := Select("Choose:", []Option{}) + if result != -1 { + t.Errorf("expected -1, got %d", result) + } + // Will be either ErrNoOptions or ErrNotInteractive + if err == nil { + t.Error("expected error for empty options") + } +} + +func TestSelectString_NonInteractive(t *testing.T) { + if IsInteractive() { + t.Skip("test requires non-interactive environment") + } + + result, err := SelectString("Choose:", []string{"A", "B"}) + if result != -1 { + t.Errorf("expected -1, got %d", result) + } + if err != ErrNotInteractive { + t.Errorf("expected ErrNotInteractive, got %v", err) + } +} + +func TestMultiSelect_NonInteractive(t *testing.T) { + if IsInteractive() { + t.Skip("test requires non-interactive environment") + } + + options := []Option{ + {Label: "Option 1"}, + {Label: "Option 2"}, + } + + result, err := MultiSelect("Choose:", options) + if result != nil { + t.Errorf("expected nil, got %v", result) + } + if err != ErrNotInteractive { + t.Errorf("expected ErrNotInteractive, got %v", err) + } +} + +func TestMultiSelect_NoOptions(t *testing.T) { + result, err := MultiSelect("Choose:", []Option{}) + if result != nil { + t.Errorf("expected nil, got %v", result) + } + // Will be either ErrNoOptions or ErrNotInteractive + if err == nil { + t.Error("expected error for empty options") + } +} + +func TestWrapText(t *testing.T) { + tests := []struct { + name string + text string + width int + expected []string + }{ + { + name: "single short line", + text: "Hello world", + width: 50, + expected: []string{"Hello world"}, + }, + { + name: "wrap long line", + text: "This is a longer line that should wrap to multiple lines", + width: 20, + expected: []string{"This is a longer", "line that should", "wrap to multiple", "lines"}, + }, + { + name: "empty text", + text: "", + width: 50, + expected: []string{""}, + }, + { + name: "multiple paragraphs", + text: "First paragraph.\n\nSecond paragraph.", + width: 50, + expected: []string{"First paragraph.", "", "Second paragraph."}, + }, + { + name: "blank lines preserved", + text: "Line 1\n\n\nLine 4", + width: 50, + expected: []string{"Line 1", "", "", "Line 4"}, + }, + { + name: "single word longer than width", + text: "supercalifragilisticexpialidocious", + width: 10, + expected: []string{"supercalifragilisticexpialidocious"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := wrapText(tc.text, tc.width) + if len(result) != len(tc.expected) { + t.Errorf("expected %d lines, got %d", len(tc.expected), len(result)) + t.Logf("result: %v", result) + return + } + for i := range result { + if result[i] != tc.expected[i] { + t.Errorf("line %d: expected %q, got %q", i, tc.expected[i], result[i]) + } + } + }) + } +} + +func TestDisplayBox(t *testing.T) { + // DisplayBox writes to stdout, just verify it doesn't panic + DisplayBox("Test Title", "Test content here.") + DisplayBox("", "No title content") + DisplayBox("Title", "Multi\nLine\nContent") +} + +func TestOption(t *testing.T) { + opt := Option{ + Label: "Test Label", + Description: "Test Description", + } + if opt.Label != "Test Label" { + t.Errorf("expected label 'Test Label', got %s", opt.Label) + } + if opt.Description != "Test Description" { + t.Errorf("expected description 'Test Description', got %s", opt.Description) + } +} + +func TestErrorMessages(t *testing.T) { + if ErrNotInteractive.Error() != "prompt: stdin is not an interactive terminal" { + t.Errorf("unexpected error message: %s", ErrNotInteractive.Error()) + } + if ErrNoOptions.Error() != "prompt: no options provided" { + t.Errorf("unexpected error message: %s", ErrNoOptions.Error()) + } + if ErrCancelled.Error() != "prompt: cancelled by user" { + t.Errorf("unexpected error message: %s", ErrCancelled.Error()) + } +} + +func TestWrapTextEdgeCases(t *testing.T) { + // Width of 1 - each word on its own line + result := wrapText("a b c", 1) + expected := []string{"a", "b", "c"} + if len(result) != len(expected) { + t.Errorf("expected %d lines, got %d", len(expected), len(result)) + } + for i := range expected { + if result[i] != expected[i] { + t.Errorf("line %d: expected %q, got %q", i, expected[i], result[i]) + } + } + + // Very long text + longText := "The quick brown fox jumps over the lazy dog. " + for i := 0; i < 5; i++ { + longText += longText + } + result = wrapText(longText, 80) + if len(result) == 0 { + t.Error("expected non-empty result for long text") + } + for i, line := range result { + if len(line) > 80 { + t.Errorf("line %d exceeds width 80: length=%d", i, len(line)) + } + } +} + +func TestSelectStringConvertsToOptions(t *testing.T) { + // Test that SelectString creates proper Options from strings + // Can't fully test without interactive input, but verify the conversion logic + // by checking the function signature and behavior match Select + if IsInteractive() { + t.Skip("test requires non-interactive environment") + } + + // Both should fail the same way in non-interactive mode + idx1, err1 := Select("Q?", []Option{{Label: "A"}, {Label: "B"}}) + idx2, err2 := SelectString("Q?", []string{"A", "B"}) + + if idx1 != idx2 { + t.Errorf("expected same index, got %d and %d", idx1, idx2) + } + if err1 != err2 { + t.Errorf("expected same error, got %v and %v", err1, err2) + } +} + +func TestDisplayBoxFormatting(t *testing.T) { + // Verify box width constant behavior + // DisplayBox uses width of 70 + content := "Short" + DisplayBox("Title", content) // Just verify no panic + + // Long content that needs wrapping + longContent := "This is a very long line that will need to be wrapped because it exceeds the box width of 70 characters" + DisplayBox("Long Content Test", longContent) +} + +func TestWrapTextPreservesIndentation(t *testing.T) { + // Verify that leading spaces in words are not stripped + text := "First line\n Indented line\n More indented" + result := wrapText(text, 50) + + // Each line should be preserved (though wrapping behavior depends on implementation) + if len(result) < 3 { + t.Errorf("expected at least 3 lines, got %d", len(result)) + } +} + +func TestWrapTextWithOnlyNewlines(t *testing.T) { + text := "\n\n\n" + result := wrapText(text, 50) + // Should produce 4 empty lines (3 newlines split into 4 segments) + if len(result) != 4 { + t.Errorf("expected 4 lines for 3 newlines, got %d", len(result)) + } + for i, line := range result { + if line != "" { + t.Errorf("line %d should be empty, got %q", i, line) + } + } +} + +func TestWrapTextExactWidth(t *testing.T) { + // Text that exactly fits the width + text := "12345" + result := wrapText(text, 5) + if len(result) != 1 { + t.Errorf("expected 1 line, got %d", len(result)) + } + if result[0] != "12345" { + t.Errorf("expected '12345', got %q", result[0]) + } +} + +func TestWrapTextWordBoundaries(t *testing.T) { + // Words that would split at boundary + text := "aaa bbb ccc" + result := wrapText(text, 7) + // "aaa bbb" = 7 chars, should fit on one line + // "ccc" on next line + if len(result) != 2 { + t.Errorf("expected 2 lines, got %d: %v", len(result), result) + } + if result[0] != "aaa bbb" { + t.Errorf("expected 'aaa bbb', got %q", result[0]) + } + if result[1] != "ccc" { + t.Errorf("expected 'ccc', got %q", result[1]) + } +} + +func TestDisplayBoxEmptyContent(t *testing.T) { + // Empty content should not panic + DisplayBox("Title", "") + DisplayBox("", "") +} + +func TestOptionWithDescription(t *testing.T) { + opt := Option{ + Label: "Build", + Description: "Build the connector binary", + } + if !strings.Contains(opt.Label, "Build") { + t.Error("label should contain 'Build'") + } + if !strings.Contains(opt.Description, "connector") { + t.Error("description should contain 'connector'") + } +} diff --git a/pkg/scaffold/scaffold.go b/pkg/scaffold/scaffold.go new file mode 100644 index 00000000..e7bf55ac --- /dev/null +++ b/pkg/scaffold/scaffold.go @@ -0,0 +1,1923 @@ +// Package scaffold provides templates for generating new connector projects. +package scaffold + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "text/template" +) + +// Config holds configuration for generating a new connector project. +type Config struct { + // Name is the connector name (e.g., "my-app") + Name string + // ModulePath is the Go module path (e.g., "github.com/myorg/baton-my-app") + ModulePath string + // OutputDir is where the project will be created + OutputDir string + // Description is a brief description of the connector + Description string +} + +// Generate creates a new connector project from the standard template. +func Generate(cfg *Config) error { + if cfg.Name == "" { + return fmt.Errorf("scaffold: connector name is required") + } + if cfg.ModulePath == "" { + cfg.ModulePath = fmt.Sprintf("github.com/conductorone/baton-%s", cfg.Name) + } + if cfg.OutputDir == "" { + cfg.OutputDir = fmt.Sprintf("baton-%s", cfg.Name) + } + if cfg.Description == "" { + cfg.Description = fmt.Sprintf("ConductorOne connector for %s", cfg.Name) + } + + // Create output directory + if err := os.MkdirAll(cfg.OutputDir, 0755); err != nil { + return fmt.Errorf("scaffold: failed to create output directory: %w", err) + } + + // Generate all template files + for _, tf := range templateFiles { + if err := generateFile(cfg, tf); err != nil { + return fmt.Errorf("scaffold: failed to generate %s: %w", tf.Path, err) + } + } + + return nil +} + +// templateFile represents a file to be generated. +type templateFile struct { + Path string + Template string + Mode os.FileMode +} + +// generateFile generates a single file from a template. +func generateFile(cfg *Config, tf templateFile) error { + // Parse template + tmpl, err := template.New(tf.Path).Parse(tf.Template) + if err != nil { + return fmt.Errorf("failed to parse template: %w", err) + } + + // Expand path template variables + expandedPath := strings.ReplaceAll(tf.Path, "{{.Name}}", cfg.Name) + + // Create directory structure + fullPath := filepath.Join(cfg.OutputDir, expandedPath) + dir := filepath.Dir(fullPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + // Create file + mode := tf.Mode + if mode == 0 { + mode = 0644 + } + f, err := os.OpenFile(fullPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + defer f.Close() + + // Execute template + data := map[string]string{ + "Name": cfg.Name, + "NameTitle": toTitleCase(cfg.Name), + "NamePascal": toPascalCase(cfg.Name), + "ModulePath": cfg.ModulePath, + "Description": cfg.Description, + } + if err := tmpl.Execute(f, data); err != nil { + return fmt.Errorf("failed to execute template: %w", err) + } + + return nil +} + +// toPascalCase converts a kebab-case string to PascalCase. +func toPascalCase(s string) string { + parts := strings.Split(s, "-") + for i, p := range parts { + if len(p) > 0 { + parts[i] = strings.ToUpper(p[:1]) + p[1:] + } + } + return strings.Join(parts, "") +} + +// toTitleCase converts a kebab-case string to Title Case. +func toTitleCase(s string) string { + parts := strings.Split(s, "-") + for i, p := range parts { + if len(p) > 0 { + parts[i] = strings.ToUpper(p[:1]) + p[1:] + } + } + return strings.Join(parts, " ") +} + +// templateFiles contains all the files to generate for a new connector. +// These templates use baton-sdk v0.4.7+ patterns with config.DefineConfiguration. +var templateFiles = []templateFile{ + { + Path: "go.mod", + Template: `module {{.ModulePath}} + +go 1.23 + +require ( + github.com/conductorone/baton-sdk v0.7.1 + github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 + go.uber.org/zap v1.27.0 +) +`, + }, + { + Path: "main.go", + Template: `package main + +import ( + "context" + "fmt" + "os" + + "{{.ModulePath}}/pkg/connector" + configSdk "github.com/conductorone/baton-sdk/pkg/config" + "github.com/conductorone/baton-sdk/pkg/connectorbuilder" + "github.com/conductorone/baton-sdk/pkg/field" + "github.com/conductorone/baton-sdk/pkg/types" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "go.uber.org/zap" +) + +var version = "dev" + +// Config holds the connector configuration. +// It implements field.Configurable to work with the SDK's configuration system. +// +// Required permissions for sync operations: +// - Read access to {{.NameTitle}} users, groups, and roles +// +// Required permissions for provisioning operations: +// - Write access to create/modify/delete users and group memberships +type Config struct { + // BaseURL is the API base URL. Override for testing against mocks. + BaseURL string ` + "`" + `mapstructure:"base-url"` + "`" + ` + // Insecure skips TLS certificate verification. ONLY use for testing. + Insecure bool ` + "`" + `mapstructure:"insecure"` + "`" + ` + // Add connector-specific fields here as needed. + // Example: APIKey string ` + "`" + `mapstructure:"api-key"` + "`" + ` +} + +// Implement field.Configurable interface. +// These methods allow the SDK to read configuration values. +// Each method should return the appropriate value for the given key. +func (c *Config) GetString(key string) string { + switch key { + case "base-url": + return c.BaseURL + default: + return "" + } +} + +func (c *Config) GetBool(key string) bool { + switch key { + case "insecure": + return c.Insecure + default: + return false + } +} + +func (c *Config) GetInt(key string) int { return 0 } +func (c *Config) GetStringSlice(key string) []string { return nil } +func (c *Config) GetStringMap(key string) map[string]any { return nil } + +// Configuration fields for the connector. +// These define CLI flags and environment variables. +var configFields = []field.SchemaField{ + // Testability: Allow overriding base URL for mock servers + field.StringField( + "base-url", + field.WithDescription("Base URL for the {{.NameTitle}} API (override for testing)"), + field.WithDefaultValue("https://api.example.com"), // TODO: Set your API's default URL + ), + // Testability: Allow skipping TLS verification for self-signed certs + field.BoolField( + "insecure", + field.WithDescription("Skip TLS certificate verification (for testing only - DO NOT USE IN PRODUCTION)"), + field.WithDefaultValue(false), + ), + // TODO: Add your connector-specific fields here, e.g.: + // field.StringField("api-key", field.WithRequired(true), field.WithDescription("API key for authentication")), +} + +// ConfigSchema is the configuration schema for the connector. +var ConfigSchema = field.NewConfiguration( + configFields, + field.WithConnectorDisplayName("{{.NameTitle}}"), +) + +func main() { + ctx := context.Background() + + _, cmd, err := configSdk.DefineConfiguration( + ctx, + "baton-{{.Name}}", + getConnector, + ConfigSchema, + ) + if err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(1) + } + + cmd.Version = version + + err = cmd.Execute() + if err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(1) + } +} + +func getConnector(ctx context.Context, cfg *Config) (types.ConnectorServer, error) { + l := ctxzap.Extract(ctx) + + // Log warning if insecure mode is enabled + if cfg.Insecure { + l.Warn("baton-{{.Name}}: TLS certificate verification disabled - DO NOT USE IN PRODUCTION") + } + + cb, err := connector.New(ctx, cfg.BaseURL, cfg.Insecure) + if err != nil { + l.Error("baton-{{.Name}}: error creating connector", zap.Error(err)) + return nil, fmt.Errorf("baton-{{.Name}}: failed to create connector: %w", err) + } + + // IMPORTANT: connectorbuilder.NewConnector wraps your connector with SDK infrastructure. + // This is REQUIRED - without it, the connector won't function. + // The wrapper provides: gRPC server, sync orchestration, pagination handling. + c, err := connectorbuilder.NewConnector(ctx, cb) + if err != nil { + l.Error("baton-{{.Name}}: error wrapping connector", zap.Error(err)) + return nil, fmt.Errorf("baton-{{.Name}}: failed to initialize connector (connectorbuilder.NewConnector failed): %w", err) + } + + return c, nil +} +`, + }, + { + Path: "pkg/connector/connector.go", + Template: `package connector + +import ( + "context" + "crypto/tls" + "fmt" + "io" + "net/http" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/annotations" + "github.com/conductorone/baton-sdk/pkg/connectorbuilder" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "go.uber.org/zap" +) + +// Connector implements the {{.Name}} connector. +type Connector struct { + baseURL string + httpClient *http.Client + // TODO: Add API client or other state here. + // Example: client *{{.NamePascal}}Client +} + +// ResourceSyncers returns a ResourceSyncer for each resource type that should be synced. +// +// Resource types: +// - user: Users from {{.NameTitle}} (principals that receive grants) +// - group: Groups with "member" entitlement +// - role: Roles with "assigned" entitlement +// +// The three fundamental resource types are: +// 1. Users - principals that can be granted access +// 2. Groups - collections of users with membership entitlement +// 3. Roles - permissions that can be assigned to users +func (c *Connector) ResourceSyncers(ctx context.Context) []connectorbuilder.ResourceSyncer { + return []connectorbuilder.ResourceSyncer{ + newUserBuilder(c), + newGroupBuilder(c), + newRoleBuilder(c), + } +} + +// Asset takes an input AssetRef and attempts to fetch it. +// Most connectors don't need to implement this. +func (c *Connector) Asset(ctx context.Context, asset *v2.AssetRef) (string, io.ReadCloser, error) { + return "", nil, nil +} + +// Metadata returns metadata about the connector. +func (c *Connector) Metadata(ctx context.Context) (*v2.ConnectorMetadata, error) { + return &v2.ConnectorMetadata{ + DisplayName: "{{.NameTitle}}", + Description: "{{.Description}}", + }, nil +} + +// Validate is called to ensure that the connector is properly configured. +// This runs before every sync to fail fast on bad credentials. +// +// Required permissions: +// - Read access to {{.NameTitle}} API (basic endpoint like /users or /me) +func (c *Connector) Validate(ctx context.Context) (annotations.Annotations, error) { + l := ctxzap.Extract(ctx) + l.Debug("baton-{{.Name}}: validating connection") + + // TODO: Implement validation - test API connection + // Example: + // _, err := c.client.GetCurrentUser(ctx) + // if err != nil { + // return nil, fmt.Errorf("baton-{{.Name}}: validation failed: %w", err) + // } + + l.Info("baton-{{.Name}}: connection validated") + return nil, nil +} + +// New returns a new instance of the connector. +// +// Parameters: +// - baseURL: API base URL (can be overridden for testing) +// - insecure: Skip TLS verification (for testing with self-signed certs) +func New(ctx context.Context, baseURL string, insecure bool) (*Connector, error) { + l := ctxzap.Extract(ctx) + + // Configure HTTP client with optional insecure TLS + httpClient := &http.Client{} + if insecure { + l.Warn("baton-{{.Name}}: TLS certificate verification disabled") + httpClient.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // Intentional for testing + }, + } + } + + l.Info("baton-{{.Name}}: creating connector", + zap.String("base_url", baseURL), + zap.Bool("insecure", insecure), + ) + + // TODO: Create API client + // Example: + // client, err := NewClient(baseURL, httpClient) + // if err != nil { + // return nil, fmt.Errorf("baton-{{.Name}}: failed to create client: %w", err) + // } + + return &Connector{ + baseURL: baseURL, + httpClient: httpClient, + }, nil +} +`, + }, + { + Path: "pkg/connector/resource_types.go", + Template: `package connector + +import ( + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" +) + +// Resource type definitions for {{.NameTitle}}. +// Each resource type maps to an entity in the target system. + +// userResourceType defines the user resource type. +// Users are principals that can receive grants to entitlements. +var userResourceType = &v2.ResourceType{ + Id: "user", + DisplayName: "User", + Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_USER}, +} + +// groupResourceType defines the group resource type. +// Groups have a "member" entitlement that users can be granted. +var groupResourceType = &v2.ResourceType{ + Id: "group", + DisplayName: "Group", + Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_GROUP}, +} + +// roleResourceType defines the role resource type. +// Roles have an "assigned" entitlement that users can be granted. +var roleResourceType = &v2.ResourceType{ + Id: "role", + DisplayName: "Role", + Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_ROLE}, +} +`, + }, + { + Path: "pkg/connector/users.go", + Template: `package connector + +import ( + "context" + "fmt" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/annotations" + "github.com/conductorone/baton-sdk/pkg/pagination" + rs "github.com/conductorone/baton-sdk/pkg/types/resource" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "go.uber.org/zap" +) + +type userBuilder struct { + conn *Connector +} + +func (u *userBuilder) ResourceType(ctx context.Context) *v2.ResourceType { + return userResourceType +} + +// List returns all the users from the upstream service as resource objects. +// +// Required permissions: +// - Read access to users in {{.NameTitle}} +func (u *userBuilder) List(ctx context.Context, parentResourceID *v2.ResourceId, pToken *pagination.Token) ([]*v2.Resource, string, annotations.Annotations, error) { + l := ctxzap.Extract(ctx) + l.Debug("baton-{{.Name}}: listing users") + + // TODO: Implement user listing with pagination + // Example: + // + // page := "" + // if pToken != nil && pToken.Token != "" { + // page = pToken.Token + // } + // + // users, nextPage, err := u.conn.client.ListUsers(ctx, page, 100) + // if err != nil { + // return nil, "", nil, fmt.Errorf("baton-{{.Name}}: failed to list users: %w", err) + // } + // + // var rv []*v2.Resource + // for _, user := range users { + // // Check context for cancellation in loops + // select { + // case <-ctx.Done(): + // return nil, "", nil, fmt.Errorf("baton-{{.Name}}: context cancelled: %w", ctx.Err()) + // default: + // } + // + // displayName := user.Name + // if displayName == "" { + // displayName = user.Email // Fall back to email if no name + // } + // + // resource, err := rs.NewUserResource( + // displayName, + // userResourceType, + // user.ID, + // []rs.UserTraitOption{ + // rs.WithEmail(user.Email, true), + // rs.WithUserLogin(user.Username), + // rs.WithStatus(v2.UserTrait_Status_STATUS_ENABLED), + // }, + // // ExternalId is CRITICAL for provisioning - stores native identifier + // rs.WithExternalID(&v2.ExternalId{ + // Id: user.ID, + // Link: fmt.Sprintf("%s/users/%s", u.conn.baseURL, user.ID), + // }), + // ) + // if err != nil { + // return nil, "", nil, fmt.Errorf("baton-{{.Name}}: failed to create user resource: %w", err) + // } + // rv = append(rv, resource) + // } + // + // l.Info("baton-{{.Name}}: listed users", zap.Int("count", len(rv))) + // return rv, nextPage, nil, nil + + // Placeholder - remove after implementing + _ = rs.NewUserResource + _ = fmt.Sprintf + l.Info("baton-{{.Name}}: user listing not implemented yet") + return nil, "", nil, nil +} + +// Entitlements always returns an empty slice for users. +// Users are principals that receive grants, not resources with grantable permissions. +func (u *userBuilder) Entitlements(_ context.Context, resource *v2.Resource, _ *pagination.Token) ([]*v2.Entitlement, string, annotations.Annotations, error) { + return nil, "", nil, nil +} + +// Grants always returns an empty slice for users. +// Grants flow from entitlements to users, not from users. +func (u *userBuilder) Grants(ctx context.Context, resource *v2.Resource, pToken *pagination.Token) ([]*v2.Grant, string, annotations.Annotations, error) { + return nil, "", nil, nil +} + +// ============================================================================= +// PROVISIONING: Create/Delete Users (ResourceManager interface) +// ============================================================================= +// Uncomment and implement these methods to support user lifecycle management. +// +// func (u *userBuilder) Create(ctx context.Context, resource *v2.Resource) (*v2.Resource, annotations.Annotations, error) { +// l := ctxzap.Extract(ctx) +// l.Info("baton-{{.Name}}: creating user") +// +// // Get user traits from the resource +// userTrait, err := rs.GetUserTrait(resource) +// if err != nil { +// return nil, nil, fmt.Errorf("baton-{{.Name}}: failed to get user trait: %w", err) +// } +// +// // TODO: Create user in upstream system +// // newUser, err := u.conn.client.CreateUser(ctx, &CreateUserRequest{ +// // Email: userTrait.GetEmail().GetAddress(), +// // Username: userTrait.GetLogin(), +// // }) +// // if err != nil { +// // return nil, nil, fmt.Errorf("baton-{{.Name}}: failed to create user: %w", err) +// // } +// +// // Return the created resource with its new ID +// // return rs.NewUserResource(newUser.Name, userResourceType, newUser.ID, ...) +// return nil, nil, fmt.Errorf("baton-{{.Name}}: user creation not implemented") +// } +// +// func (u *userBuilder) Delete(ctx context.Context, resourceId *v2.ResourceId) (annotations.Annotations, error) { +// l := ctxzap.Extract(ctx) +// l.Info("baton-{{.Name}}: deleting user", zap.String("id", resourceId.Resource)) +// +// // TODO: Delete user from upstream system +// // err := u.conn.client.DeleteUser(ctx, resourceId.Resource) +// // if err != nil { +// // return nil, fmt.Errorf("baton-{{.Name}}: failed to delete user: %w", err) +// // } +// // return nil, nil +// return nil, fmt.Errorf("baton-{{.Name}}: user deletion not implemented") +// } + +func newUserBuilder(conn *Connector) *userBuilder { + return &userBuilder{conn: conn} +} +`, + }, + { + Path: "pkg/connector/groups.go", + Template: `package connector + +import ( + "context" + "fmt" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/annotations" + "github.com/conductorone/baton-sdk/pkg/pagination" + ent "github.com/conductorone/baton-sdk/pkg/types/entitlement" + "github.com/conductorone/baton-sdk/pkg/types/grant" + rs "github.com/conductorone/baton-sdk/pkg/types/resource" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "go.uber.org/zap" +) + +const memberEntitlement = "member" + +type groupBuilder struct { + conn *Connector +} + +func (g *groupBuilder) ResourceType(ctx context.Context) *v2.ResourceType { + return groupResourceType +} + +// List returns all groups from the upstream service. +// +// Required permissions: +// - Read access to groups in {{.NameTitle}} +func (g *groupBuilder) List(ctx context.Context, parentResourceID *v2.ResourceId, pToken *pagination.Token) ([]*v2.Resource, string, annotations.Annotations, error) { + l := ctxzap.Extract(ctx) + l.Debug("baton-{{.Name}}: listing groups") + + // TODO: Implement group listing with pagination + // Example: + // + // page := "" + // if pToken != nil && pToken.Token != "" { + // page = pToken.Token + // } + // + // groups, nextPage, err := g.conn.client.ListGroups(ctx, page, 100) + // if err != nil { + // return nil, "", nil, fmt.Errorf("baton-{{.Name}}: failed to list groups: %w", err) + // } + // + // var rv []*v2.Resource + // for _, group := range groups { + // resource, err := rs.NewGroupResource( + // group.Name, + // groupResourceType, + // group.ID, + // []rs.GroupTraitOption{ + // rs.WithGroupProfile(map[string]interface{}{ + // "description": group.Description, + // }), + // }, + // rs.WithExternalID(&v2.ExternalId{Id: group.ID}), + // ) + // if err != nil { + // return nil, "", nil, fmt.Errorf("baton-{{.Name}}: failed to create group resource: %w", err) + // } + // rv = append(rv, resource) + // } + // + // l.Info("baton-{{.Name}}: listed groups", zap.Int("count", len(rv))) + // return rv, nextPage, nil, nil + + _ = rs.NewGroupResource + _ = fmt.Sprintf + l.Info("baton-{{.Name}}: group listing not implemented yet") + return nil, "", nil, nil +} + +// Entitlements returns the "member" entitlement for the group. +// This entitlement can be granted to users to make them members of the group. +func (g *groupBuilder) Entitlements(ctx context.Context, resource *v2.Resource, pToken *pagination.Token) ([]*v2.Entitlement, string, annotations.Annotations, error) { + // Create the "member" entitlement for this group + entitlement := ent.NewAssignmentEntitlement( + resource, + memberEntitlement, + ent.WithGrantableTo(userResourceType), + ent.WithDisplayName(fmt.Sprintf("%s Group Member", resource.DisplayName)), + ent.WithDescription(fmt.Sprintf("Member of the %s group", resource.DisplayName)), + ) + + return []*v2.Entitlement{entitlement}, "", nil, nil +} + +// Grants returns all users who are members of this group. +// +// Required permissions: +// - Read access to group memberships in {{.NameTitle}} +func (g *groupBuilder) Grants(ctx context.Context, resource *v2.Resource, pToken *pagination.Token) ([]*v2.Grant, string, annotations.Annotations, error) { + l := ctxzap.Extract(ctx) + l.Debug("baton-{{.Name}}: listing group members", zap.String("group_id", resource.Id.Resource)) + + // TODO: Implement group membership listing + // Example: + // + // groupID := resource.Id.Resource + // + // page := "" + // if pToken != nil && pToken.Token != "" { + // page = pToken.Token + // } + // + // members, nextPage, err := g.conn.client.ListGroupMembers(ctx, groupID, page, 100) + // if err != nil { + // return nil, "", nil, fmt.Errorf("baton-{{.Name}}: failed to list group members: %w", err) + // } + // + // var rv []*v2.Grant + // for _, member := range members { + // grant := grant.NewGrant( + // resource, + // memberEntitlement, + // &v2.ResourceId{ + // ResourceType: userResourceType.Id, + // Resource: member.UserID, + // }, + // ) + // rv = append(rv, grant) + // } + // + // l.Info("baton-{{.Name}}: listed group members", zap.Int("count", len(rv))) + // return rv, nextPage, nil, nil + + _ = grant.NewGrant + l.Info("baton-{{.Name}}: group grants not implemented yet") + return nil, "", nil, nil +} + +// ============================================================================= +// PROVISIONING: Grant/Revoke group membership (ResourceProvisioner interface) +// ============================================================================= +// Uncomment to support group membership provisioning. +// +// func (g *groupBuilder) Grant(ctx context.Context, principal *v2.Resource, entitlement *v2.Entitlement) (annotations.Annotations, error) { +// l := ctxzap.Extract(ctx) +// groupID := entitlement.Resource.Id.Resource +// userID := principal.Id.Resource +// l.Info("baton-{{.Name}}: granting group membership", zap.String("group", groupID), zap.String("user", userID)) +// // TODO: Add user to group in upstream system +// // err := g.conn.client.AddGroupMember(ctx, groupID, userID) +// // if err != nil { +// // return nil, fmt.Errorf("baton-{{.Name}}: failed to grant membership: %w", err) +// // } +// // return nil, nil +// return nil, fmt.Errorf("baton-{{.Name}}: grant not implemented") +// } +// +// func (g *groupBuilder) Revoke(ctx context.Context, grantToRevoke *v2.Grant) (annotations.Annotations, error) { +// l := ctxzap.Extract(ctx) +// groupID := grantToRevoke.Entitlement.Resource.Id.Resource +// userID := grantToRevoke.Principal.Id.Resource +// l.Info("baton-{{.Name}}: revoking group membership", zap.String("group", groupID), zap.String("user", userID)) +// // TODO: Remove user from group in upstream system +// // err := g.conn.client.RemoveGroupMember(ctx, groupID, userID) +// // if err != nil { +// // return nil, fmt.Errorf("baton-{{.Name}}: failed to revoke membership: %w", err) +// // } +// // return nil, nil +// return nil, fmt.Errorf("baton-{{.Name}}: revoke not implemented") +// } + +func newGroupBuilder(conn *Connector) *groupBuilder { + return &groupBuilder{conn: conn} +} +`, + }, + { + Path: "pkg/connector/roles.go", + Template: `package connector + +import ( + "context" + "fmt" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/annotations" + "github.com/conductorone/baton-sdk/pkg/pagination" + ent "github.com/conductorone/baton-sdk/pkg/types/entitlement" + "github.com/conductorone/baton-sdk/pkg/types/grant" + rs "github.com/conductorone/baton-sdk/pkg/types/resource" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "go.uber.org/zap" +) + +const assignedEntitlement = "assigned" + +type roleBuilder struct { + conn *Connector +} + +func (r *roleBuilder) ResourceType(ctx context.Context) *v2.ResourceType { + return roleResourceType +} + +// List returns all roles from the upstream service. +// +// Required permissions: +// - Read access to roles in {{.NameTitle}} +func (r *roleBuilder) List(ctx context.Context, parentResourceID *v2.ResourceId, pToken *pagination.Token) ([]*v2.Resource, string, annotations.Annotations, error) { + l := ctxzap.Extract(ctx) + l.Debug("baton-{{.Name}}: listing roles") + + // TODO: Implement role listing with pagination + // Example: + // + // page := "" + // if pToken != nil && pToken.Token != "" { + // page = pToken.Token + // } + // + // roles, nextPage, err := r.conn.client.ListRoles(ctx, page, 100) + // if err != nil { + // return nil, "", nil, fmt.Errorf("baton-{{.Name}}: failed to list roles: %w", err) + // } + // + // var rv []*v2.Resource + // for _, role := range roles { + // resource, err := rs.NewRoleResource( + // role.Name, + // roleResourceType, + // role.ID, + // []rs.RoleTraitOption{ + // rs.WithRoleProfile(map[string]interface{}{ + // "description": role.Description, + // "permissions": role.Permissions, + // }), + // }, + // rs.WithExternalID(&v2.ExternalId{Id: role.ID}), + // ) + // if err != nil { + // return nil, "", nil, fmt.Errorf("baton-{{.Name}}: failed to create role resource: %w", err) + // } + // rv = append(rv, resource) + // } + // + // l.Info("baton-{{.Name}}: listed roles", zap.Int("count", len(rv))) + // return rv, nextPage, nil, nil + + _ = rs.NewRoleResource + _ = fmt.Sprintf + l.Info("baton-{{.Name}}: role listing not implemented yet") + return nil, "", nil, nil +} + +// Entitlements returns the "assigned" entitlement for the role. +// This entitlement can be granted to users to assign them the role. +func (r *roleBuilder) Entitlements(ctx context.Context, resource *v2.Resource, pToken *pagination.Token) ([]*v2.Entitlement, string, annotations.Annotations, error) { + // Create the "assigned" entitlement for this role + entitlement := ent.NewAssignmentEntitlement( + resource, + assignedEntitlement, + ent.WithGrantableTo(userResourceType), + ent.WithDisplayName(fmt.Sprintf("%s Role", resource.DisplayName)), + ent.WithDescription(fmt.Sprintf("Assigned the %s role", resource.DisplayName)), + ) + + return []*v2.Entitlement{entitlement}, "", nil, nil +} + +// Grants returns all users who are assigned this role. +// +// Required permissions: +// - Read access to role assignments in {{.NameTitle}} +func (r *roleBuilder) Grants(ctx context.Context, resource *v2.Resource, pToken *pagination.Token) ([]*v2.Grant, string, annotations.Annotations, error) { + l := ctxzap.Extract(ctx) + l.Debug("baton-{{.Name}}: listing role assignments", zap.String("role_id", resource.Id.Resource)) + + // TODO: Implement role assignment listing + // Example: + // + // roleID := resource.Id.Resource + // + // page := "" + // if pToken != nil && pToken.Token != "" { + // page = pToken.Token + // } + // + // assignments, nextPage, err := r.conn.client.ListRoleAssignments(ctx, roleID, page, 100) + // if err != nil { + // return nil, "", nil, fmt.Errorf("baton-{{.Name}}: failed to list role assignments: %w", err) + // } + // + // var rv []*v2.Grant + // for _, assignment := range assignments { + // grant := grant.NewGrant( + // resource, + // assignedEntitlement, + // &v2.ResourceId{ + // ResourceType: userResourceType.Id, + // Resource: assignment.UserID, + // }, + // ) + // rv = append(rv, grant) + // } + // + // l.Info("baton-{{.Name}}: listed role assignments", zap.Int("count", len(rv))) + // return rv, nextPage, nil, nil + + _ = grant.NewGrant + l.Info("baton-{{.Name}}: role grants not implemented yet") + return nil, "", nil, nil +} + +// ============================================================================= +// PROVISIONING: Grant/Revoke role assignment (ResourceProvisioner interface) +// ============================================================================= +// Uncomment to support role assignment provisioning. +// +// func (r *roleBuilder) Grant(ctx context.Context, principal *v2.Resource, entitlement *v2.Entitlement) (annotations.Annotations, error) { +// l := ctxzap.Extract(ctx) +// roleID := entitlement.Resource.Id.Resource +// userID := principal.Id.Resource +// l.Info("baton-{{.Name}}: granting role", zap.String("role", roleID), zap.String("user", userID)) +// // TODO: Assign role to user in upstream system +// return nil, fmt.Errorf("baton-{{.Name}}: grant not implemented") +// } +// +// func (r *roleBuilder) Revoke(ctx context.Context, grantToRevoke *v2.Grant) (annotations.Annotations, error) { +// l := ctxzap.Extract(ctx) +// roleID := grantToRevoke.Entitlement.Resource.Id.Resource +// userID := grantToRevoke.Principal.Id.Resource +// l.Info("baton-{{.Name}}: revoking role", zap.String("role", roleID), zap.String("user", userID)) +// // TODO: Unassign role from user in upstream system +// return nil, fmt.Errorf("baton-{{.Name}}: revoke not implemented") +// } + +func newRoleBuilder(conn *Connector) *roleBuilder { + return &roleBuilder{conn: conn} +} +`, + }, + { + Path: "pkg/connector/actions.go", + Template: `package connector + +import ( + "context" + "fmt" + + config "github.com/conductorone/baton-sdk/pb/c1/config/v1" + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/actions" + "github.com/conductorone/baton-sdk/pkg/annotations" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "go.uber.org/zap" + "google.golang.org/protobuf/types/known/structpb" +) + +// ============================================================================= +// BATON ACTIONS: Custom operations exposed to ConductorOne +// ============================================================================= +// +// Actions are arbitrary operations your connector can perform. Unlike Grant/Revoke +// (which modify access) or Create/Delete (which manage resources), Actions are +// general-purpose operations that ConductorOne can trigger. +// +// Common action types: +// - ACTION_TYPE_ACCOUNT_ENABLE / ACTION_TYPE_ACCOUNT_DISABLE +// - ACTION_TYPE_ACCOUNT_UPDATE_PROFILE +// - Custom operations specific to your system +// +// To enable actions, uncomment GlobalActions and the action handlers below. + +// Example: Disable account action schema +var disableAccountAction = &v2.BatonActionSchema{ + Name: "disableAccount", + Arguments: []*config.Field{ + { + Name: "accountId", + DisplayName: "Account ID", + Description: "The ID of the account to disable", + Field: &config.Field_StringField{}, + IsRequired: true, + }, + }, + ReturnTypes: []*config.Field{ + { + Name: "success", + DisplayName: "Success", + Field: &config.Field_BoolField{}, + }, + }, + ActionType: []v2.ActionType{ + v2.ActionType_ACTION_TYPE_ACCOUNT, + v2.ActionType_ACTION_TYPE_ACCOUNT_DISABLE, + }, +} + +// Example: Enable account action schema +var enableAccountAction = &v2.BatonActionSchema{ + Name: "enableAccount", + Arguments: []*config.Field{ + { + Name: "accountId", + DisplayName: "Account ID", + Description: "The ID of the account to enable", + Field: &config.Field_StringField{}, + IsRequired: true, + }, + }, + ReturnTypes: []*config.Field{ + { + Name: "success", + DisplayName: "Success", + Field: &config.Field_BoolField{}, + }, + }, + ActionType: []v2.ActionType{ + v2.ActionType_ACTION_TYPE_ACCOUNT, + v2.ActionType_ACTION_TYPE_ACCOUNT_ENABLE, + }, +} + +// GlobalActions registers custom actions with the SDK. +// Uncomment to enable actions. +// +// func (c *Connector) GlobalActions(ctx context.Context, registry actions.ActionRegistry) error { +// if err := registry.Register(ctx, disableAccountAction, c.disableAccount); err != nil { +// return err +// } +// if err := registry.Register(ctx, enableAccountAction, c.enableAccount); err != nil { +// return err +// } +// return nil +// } + +func (c *Connector) disableAccount(ctx context.Context, args *structpb.Struct) (*structpb.Struct, annotations.Annotations, error) { + l := ctxzap.Extract(ctx) + + accountId, ok := args.Fields["accountId"] + if !ok { + return nil, nil, fmt.Errorf("missing required argument accountId") + } + + l.Info("baton-{{.Name}}: disabling account", zap.String("accountId", accountId.GetStringValue())) + + // TODO: Implement account disabling in upstream system + // err := c.client.DisableUser(ctx, accountId.GetStringValue()) + // if err != nil { + // return nil, nil, fmt.Errorf("baton-{{.Name}}: failed to disable account: %w", err) + // } + + response := &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "success": structpb.NewBoolValue(true), + }, + } + return response, nil, nil +} + +func (c *Connector) enableAccount(ctx context.Context, args *structpb.Struct) (*structpb.Struct, annotations.Annotations, error) { + l := ctxzap.Extract(ctx) + + accountId, ok := args.Fields["accountId"] + if !ok { + return nil, nil, fmt.Errorf("missing required argument accountId") + } + + l.Info("baton-{{.Name}}: enabling account", zap.String("accountId", accountId.GetStringValue())) + + // TODO: Implement account enabling in upstream system + // err := c.client.EnableUser(ctx, accountId.GetStringValue()) + // if err != nil { + // return nil, nil, fmt.Errorf("baton-{{.Name}}: failed to enable account: %w", err) + // } + + response := &structpb.Struct{ + Fields: map[string]*structpb.Value{ + "success": structpb.NewBoolValue(true), + }, + } + return response, nil, nil +} + +// Ensure imports are used (remove after implementing) +var _ = actions.ActionRegistry(nil) +var _ = disableAccountAction +var _ = enableAccountAction +`, + }, + { + Path: ".gitignore", + Template: `# Binaries +baton-{{.Name}} +*.exe +*.dll +*.so +*.dylib + +# Test coverage +*.out +coverage.html + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Build artifacts +dist/ +c1z/ +*.c1z + +# Environment +.env +.env.local +`, + }, + { + Path: ".env.example", + Template: `# {{.NameTitle}} Connector Configuration +# Copy this file to .env and fill in your values +# All variables use the BATON_ prefix + +# ============================================================================= +# Target System Authentication +# ============================================================================= + +# API key for {{.NameTitle}} (if using API key auth) +# BATON_API_KEY=your-api-key-here + +# Bearer token for {{.NameTitle}} (if using bearer auth) +# BATON_BEARER_TOKEN=your-bearer-token-here + +# ============================================================================= +# ConductorOne Authentication (for daemon mode) +# ============================================================================= + +# Client credentials for ConductorOne integration +# Get these from ConductorOne admin console +# BATON_CLIENT_ID=your-client-id +# BATON_CLIENT_SECRET=your-client-secret + +# ============================================================================= +# Testing and Development +# ============================================================================= + +# Override base URL (for testing against mocks) +# BATON_BASE_URL=http://localhost:8089 + +# Skip TLS verification (ONLY for local testing with self-signed certs) +# BATON_INSECURE=true + +# ============================================================================= +# Logging and Observability +# ============================================================================= + +# Log level: debug, info, warn, error +BATON_LOG_LEVEL=info + +# Log format: json, console +BATON_LOG_FORMAT=json + +# OpenTelemetry collector endpoint (optional) +# BATON_OTEL_COLLECTOR_ENDPOINT=http://localhost:4317 +`, + }, + { + Path: "README.md", + Template: `# baton-{{.Name}} + +{{.Description}} + +## Prerequisites + +- Go 1.23+ + +## Installation + +` + "```" + `bash +go install {{.ModulePath}}@latest +` + "```" + ` + +## Configuration + +Copy ` + "`.env.example`" + ` to ` + "`.env`" + ` and fill in your values: + +` + "```" + `bash +cp .env.example .env +# Edit .env with your credentials +` + "```" + ` + +Or pass configuration via CLI flags: + +` + "```" + `bash +baton-{{.Name}} --api-key=your-key +` + "```" + ` + +See all options with ` + "`baton-{{.Name}} --help`" + `. + +## Usage + +` + "```" + `bash +# Run sync (outputs to sync.c1z) +baton-{{.Name}} + +# Run with specific output file +baton-{{.Name}} -f output.c1z + +# See all options +baton-{{.Name}} --help +` + "```" + ` + +## Development + +` + "```" + `bash +# Build +make build + +# Format, vet, and build +make check + +# Run all validations (tidy, fmt, vet, lint, test, build) +make all + +# Run tests +make test + +# Run tests with coverage +make test-cover + +# Format code +make fmt + +# Run linter +make lint +` + "```" + ` + +## Resources + +This connector syncs the following resources: + +| Resource Type | Description | +|---------------|-------------| +| User | {{.NameTitle}} users | + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Run tests: ` + "`go test ./...`" + ` +5. Submit a pull request +`, + }, + { + Path: "Makefile", + Template: `.PHONY: build test test-mock clean lint fmt vet tidy check all + +BINARY_NAME=baton-{{.Name}} +MOCK_PORT?=8089 + +# Build the connector binary +build: + go build -o $(BINARY_NAME) . + +# Run unit tests +test: + go test -v ./... + +# Run tests with coverage +test-cover: + go test -v -coverprofile=coverage.out ./... + go tool cover -html=coverage.out -o coverage.html + +# Run integration tests against a mock server +# Assumes mock server is running on localhost:$(MOCK_PORT) +test-mock: build + ./$(BINARY_NAME) --base-url=http://localhost:$(MOCK_PORT) --insecure + +# Remove build artifacts +clean: + rm -f $(BINARY_NAME) + rm -f coverage.out coverage.html + rm -rf dist/ + +# Run golangci-lint +lint: + golangci-lint run + +# Format code +fmt: + go fmt ./... + +# Run go vet +vet: + go vet ./... + +# Tidy and verify dependencies +tidy: + go mod tidy + go mod verify + +# Quick check: fmt, vet, build +check: fmt vet build + +# Full validation: tidy, fmt, vet, lint, test, build +all: tidy fmt vet lint test build + +.DEFAULT_GOAL := build +`, + }, + { + Path: "CLAUDE.md", + Template: `# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with this connector. + +## Project Overview + +This is a ConductorOne Baton connector for {{.NameTitle}}. It syncs identity and access data from {{.NameTitle}} into ConductorOne. + +## Build and Run + +` + "```" + `bash +# Build +go build -o baton-{{.Name}} . + +# Run sync +./baton-{{.Name}} + +# Run with verbose logging +./baton-{{.Name}} --log-level debug +` + "```" + ` + +## Standard Connector Structure + +` + "```" + ` +baton-{{.Name}}/ + main.go # Entry point: config, connector init + pkg/connector/ + connector.go # Metadata, ResourceSyncers(), Validate() + resource_types.go # Resource type definitions + users.go # User resource syncer + groups.go # Group resource syncer (add as needed) + pkg/client/ # Optional: API wrapper + CLAUDE.md # This file +` + "```" + ` + +## Key Patterns + +### 1. ResourceSyncer Interface + +Every resource type implements: +- ` + "`" + `List()` + "`" + ` - Return all resources (with pagination) +- ` + "`" + `Entitlements()` + "`" + ` - Return available permissions for a resource +- ` + "`" + `Grants()` + "`" + ` - Return who has what permissions + +### 2. Error Wrapping + +Always prefix errors with connector name: +` + "```" + `go +return nil, fmt.Errorf("baton-{{.Name}}: failed to list users: %w", err) +` + "```" + ` + +### 3. Pagination + +Always paginate API calls: +` + "```" + `go +func (u *userBuilder) List(ctx context.Context, parentResourceID *v2.ResourceId, pToken *pagination.Token) ([]*v2.Resource, string, annotations.Annotations, error) { + // Get page from token + page, _ := parsePageToken(pToken.Token) + + // Fetch one page + users, nextCursor, err := u.client.ListUsers(ctx, page, pageSize) + + // Return next token + return resources, nextCursor, nil, nil +} +` + "```" + ` + +### 4. User Resource Creation + +` + "```" + `go +import rs "github.com/conductorone/baton-sdk/pkg/types/resource" + +resource, err := rs.NewUserResource( + user.DisplayName, + userResourceType, + user.ID, + []rs.UserTraitOption{ + rs.WithEmail(user.Email, true), + rs.WithUserLogin(user.Username), + rs.WithStatus(v2.UserTrait_Status_STATUS_ENABLED), + }, +) +` + "```" + ` + +## Testing Requirements + +### Configurable Base URL + +The connector MUST support ` + "`" + `--base-url` + "`" + ` flag for testing against mocks: +` + "```" + `go +field.StringField("base-url", + field.WithDescription("Base URL for API (for testing)"), + field.WithDefaultValue("https://api.example.com"), +), +` + "```" + ` + +### Insecure TLS Option + +Support ` + "`" + `--insecure` + "`" + ` for self-signed certs in testing: +` + "```" + `go +field.BoolField("insecure", + field.WithDescription("Skip TLS verification (testing only)"), +), +` + "```" + ` + +## Common Pitfalls + +1. **Don't swallow errors** - Always return errors, don't log and continue +2. **Don't buffer entire datasets** - Always paginate +3. **Don't ignore context** - Pass ctx to all API calls +4. **Don't log credentials** - Never log tokens or API keys +5. **Don't use empty display names** - Fall back to ID if name is empty + +## Work Tracking + +Track work in TODO.md: +- Create TODO.md for pending tasks +- Move completed items to COMPLETED section +- Add QUESTIONS section for clarifications needed + +## Reference + +For comprehensive patterns and best practices, see: +- baton-demo: https://github.com/conductorone/baton-demo +- baton-github: https://github.com/conductorone/baton-github +- baton-sdk docs: https://github.com/conductorone/baton-sdk +`, + }, + { + Path: "docs/README.md", + Template: `# Documentation + +Place documentation about the downstream {{.NameTitle}} API here. + +This helps Claude Code understand the API and build appropriate mocks for testing. + +## Suggested Content + +1. **API Authentication** - How to authenticate (API key, OAuth, etc.) +2. **Endpoints** - Key endpoints the connector needs +3. **Data Models** - User, group, role structures +4. **Rate Limits** - API rate limiting behavior +5. **Pagination** - How the API paginates results + +## Example + +` + "```" + ` +GET /api/v1/users +Authorization: Bearer {token} + +Response: +{ + "users": [...], + "next_cursor": "abc123" +} +` + "```" + ` +`, + }, + { + Path: "docs/.gitignore", + Template: `# Ignore everything in docs except README and API_NOTES +* +!.gitignore +!README.md +!API_NOTES.md +`, + }, + { + Path: "docs/API_NOTES.md", + Template: `# {{.NameTitle}} API Notes + +This document captures API behavior, quirks, and implementation notes discovered during connector development. + +## Authentication + +` + "```" + ` +# TODO: Document authentication method +# Example: +# Authorization: Bearer {api_key} +# X-API-Key: {api_key} +` + "```" + ` + +## Pagination + +` + "```" + ` +# TODO: Document pagination pattern +# Example cursor-based: +# GET /users?cursor=abc123&limit=100 +# Response: { "users": [...], "next_cursor": "def456" } +# +# Example offset-based: +# GET /users?page=2&per_page=100 +# Response: { "users": [...], "total": 1234 } +` + "```" + ` + +## Rate Limits + +` + "```" + ` +# TODO: Document rate limits +# Example: +# X-RateLimit-Limit: 1000 +# X-RateLimit-Remaining: 999 +# X-RateLimit-Reset: 1234567890 +` + "```" + ` + +## Key Endpoints + +### Users + +` + "```" + ` +# List users +GET /api/v1/users + +# Get single user +GET /api/v1/users/{id} + +# Response shape +{ + "id": "user-123", + "email": "user@example.com", + "name": "Display Name", + "status": "active" +} +` + "```" + ` + +### Groups (if applicable) + +` + "```" + ` +# TODO: Document group endpoints +` + "```" + ` + +### Roles/Permissions (if applicable) + +` + "```" + ` +# TODO: Document role/permission endpoints +` + "```" + ` + +## Quirks and Gotchas + +- TODO: Document any API quirks discovered during implementation +- Example: "User IDs are case-sensitive" +- Example: "Empty arrays are returned as null, not []" +- Example: "Deleted users still appear in list with status=deleted" + +## Mock Server Notes + +When building a mock server for testing, ensure it: +1. Returns proper pagination tokens +2. Handles the authentication header +3. Returns realistic response shapes + +See ` + "`" + `mocks/` + "`" + ` directory for mock server implementation. +`, + }, + { + Path: ".github/workflows/ci.yaml", + Template: `name: ci + +on: + pull_request: + types: [opened, reopened, synchronize] + push: + branches: + - main + +jobs: + go-lint: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + - name: Run linters + uses: golangci/golangci-lint-action@v8 + with: + version: latest + args: --timeout=3m + + go-test: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + - name: Run tests + run: go test -v -covermode=count ./... + + build: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + - name: Build connector + run: go build -o baton-{{.Name}} . + - name: Verify binary runs + run: ./baton-{{.Name}} --help +`, + }, + { + Path: ".github/workflows/release.yaml", + Template: `name: Release + +on: + push: + tags: + - "v*" + +jobs: + release: + # Uses ConductorOne's shared release workflow + # Documentation: https://github.com/ConductorOne/github-workflows + uses: ConductorOne/github-workflows/.github/workflows/release.yaml@v2 + with: + tag: ${{"{{"}} github.ref_name {{"}}"}} + lambda: false # Set to true if you need Lambda deployment + secrets: + RELENG_GITHUB_TOKEN: ${{"{{"}} secrets.RELENG_GITHUB_TOKEN {{"}}"}} + APPLE_SIGNING_KEY_P12: ${{"{{"}} secrets.APPLE_SIGNING_KEY_P12 {{"}}"}} + APPLE_SIGNING_KEY_P12_PASSWORD: ${{"{{"}} secrets.APPLE_SIGNING_KEY_P12_PASSWORD {{"}}"}} + AC_PASSWORD: ${{"{{"}} secrets.AC_PASSWORD {{"}}"}} + AC_PROVIDER: ${{"{{"}} secrets.AC_PROVIDER {{"}}"}} + DATADOG_API_KEY: ${{"{{"}} secrets.DATADOG_API_KEY {{"}}"}} +`, + }, + { + Path: ".golangci.yml", + Template: `version: "2" +linters: + default: none + enable: + - asasalint + - asciicheck + - bidichk + - bodyclose + - durationcheck + - errcheck + - errorlint + - exhaustive + - goconst + - gocritic + - godot + - gosec + - govet + - ineffassign + - nakedret + - nilerr + - noctx + - revive + - staticcheck + - unconvert + - unused + - whitespace + settings: + exhaustive: + default-signifies-exhaustive: true + govet: + enable-all: true + disable: + - fieldalignment + - shadow + nakedret: + max-func-lines: 0 +`, + }, + { + Path: "Dockerfile", + Template: `# Build stage +FROM golang:1.23-alpine AS builder +WORKDIR /app + +# Copy go mod files first for better caching +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source code +COPY . . + +# Build static binary +RUN CGO_ENABLED=0 GOOS=linux go build -o /build/baton-{{.Name}} . + +# Runtime stage - distroless for security +# - No shell, no package manager (minimal attack surface) +# - Runs as nonroot user (uid 65534) +# - Only includes: CA certificates, timezone data +FROM gcr.io/distroless/static-debian11:nonroot + +# Copy binary from build stage +COPY --from=builder /build/baton-{{.Name}} / + +# Run as nonroot user (distroless default) +USER 65534 + +# Set entrypoint +ENTRYPOINT ["/baton-{{.Name}}"] + +# OCI metadata labels +LABEL org.opencontainers.image.title="baton-{{.Name}}" +LABEL org.opencontainers.image.description="{{.Description}}" +LABEL org.opencontainers.image.source="{{.ModulePath}}" +`, + }, + { + Path: "Dockerfile.lambda", + Template: `# Lambda deployment variant +# Use this for AWS Lambda deployments + +# Build stage +FROM golang:1.23-alpine AS builder +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +# Build with Lambda support tag +RUN CGO_ENABLED=0 GOOS=linux go build -tags baton_lambda_support -o /build/baton-{{.Name}} . + +# Runtime stage - AWS Lambda provided runtime +FROM public.ecr.aws/lambda/provided:al2023 + +# Copy binary +COPY --from=builder /build/baton-{{.Name}} /var/task/ + +# Lambda entrypoint (note: "lambda" argument triggers Lambda mode) +ENTRYPOINT ["/var/task/baton-{{.Name}}", "lambda"] +`, + }, + { + Path: "docker-compose.yml", + Template: `# Docker Compose for local development and testing +# +# Usage: +# docker compose up # Run connector in daemon mode +# docker compose run baton # Run one-shot sync +# +version: '3.9' + +services: + baton: + build: . + environment: + # ConductorOne daemon mode credentials + # Required for long-running daemon mode + - BATON_CLIENT_ID=${BATON_CLIENT_ID:-} + - BATON_CLIENT_SECRET=${BATON_CLIENT_SECRET:-} + # Connector-specific credentials + # TODO: Add your API credentials here + # - BATON_API_KEY=${BATON_API_KEY:-} + # Uncomment for one-shot mode (sync to file): + # volumes: + # - ./output:/work + # command: ["-f", "/work/sync.c1z"] + + # Optional: Mock server for testing + # mock: + # build: + # context: ./mocks + # ports: + # - "8089:8089" +`, + }, + { + Path: "pkg/client/client.go", + Template: `// Package client provides an HTTP client for the {{.NameTitle}} API. +// +// This package wraps the {{.NameTitle}} REST API with Go types and handles: +// - Authentication +// - Pagination +// - Error handling +// - Rate limiting (optional) +package client + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +// Client wraps the {{.NameTitle}} API. +type Client struct { + baseURL string + httpClient *http.Client + // TODO: Add authentication fields + // Example: apiKey string +} + +// New creates a new {{.NameTitle}} API client. +// +// Parameters: +// - baseURL: API base URL (e.g., "https://api.example.com") +// - httpClient: HTTP client (can be configured for insecure TLS in tests) +func New(baseURL string, httpClient *http.Client) (*Client, error) { + if baseURL == "" { + return nil, fmt.Errorf("baseURL is required") + } + if httpClient == nil { + httpClient = http.DefaultClient + } + + // Parse and validate base URL + u, err := url.Parse(baseURL) + if err != nil { + return nil, fmt.Errorf("invalid baseURL: %w", err) + } + + return &Client{ + baseURL: u.String(), + httpClient: httpClient, + }, nil +} + +// User represents a user from the {{.NameTitle}} API. +// TODO: Update fields to match actual API response. +type User struct { + ID string ` + "`" + `json:"id"` + "`" + ` + Email string ` + "`" + `json:"email"` + "`" + ` + Name string ` + "`" + `json:"name"` + "`" + ` + Username string ` + "`" + `json:"username,omitempty"` + "`" + ` + Status string ` + "`" + `json:"status"` + "`" + ` +} + +// ListUsersResponse is the API response for listing users. +// TODO: Update to match actual API response shape. +type ListUsersResponse struct { + Users []User ` + "`" + `json:"users"` + "`" + ` + NextCursor string ` + "`" + `json:"next_cursor,omitempty"` + "`" + ` +} + +// ListUsers returns a page of users from the API. +// +// Parameters: +// - cursor: Pagination cursor (empty for first page) +// - limit: Maximum users to return per page +// +// Returns: +// - users: List of users +// - nextCursor: Cursor for next page (empty if no more pages) +func (c *Client) ListUsers(ctx context.Context, cursor string, limit int) ([]User, string, error) { + // Build request URL + reqURL := fmt.Sprintf("%s/api/v1/users?limit=%d", c.baseURL, limit) + if cursor != "" { + reqURL += "&cursor=" + url.QueryEscape(cursor) + } + + // Create request + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, "", fmt.Errorf("failed to create request: %w", err) + } + + // TODO: Add authentication + // req.Header.Set("Authorization", "Bearer "+c.apiKey) + + // Execute request + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, "", fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + // Check status + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, "", fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) + } + + // Parse response + var result ListUsersResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, "", fmt.Errorf("failed to decode response: %w", err) + } + + return result.Users, result.NextCursor, nil +} + +// GetUser returns a single user by ID. +func (c *Client) GetUser(ctx context.Context, userID string) (*User, error) { + reqURL := fmt.Sprintf("%s/api/v1/users/%s", c.baseURL, url.PathEscape(userID)) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + // TODO: Add authentication + // req.Header.Set("Authorization", "Bearer "+c.apiKey) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("user not found: %s", userID) + } + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) + } + + var user User + if err := json.NewDecoder(resp.Body).Decode(&user); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &user, nil +} +`, + }, +} diff --git a/pkg/scaffold/scaffold_test.go b/pkg/scaffold/scaffold_test.go new file mode 100644 index 00000000..782e37c5 --- /dev/null +++ b/pkg/scaffold/scaffold_test.go @@ -0,0 +1,254 @@ +package scaffold + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestGenerate(t *testing.T) { + // Create temp directory + tmpDir, err := os.MkdirTemp("", "scaffold-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + outputDir := filepath.Join(tmpDir, "baton-test-app") + + cfg := &Config{ + Name: "test-app", + ModulePath: "github.com/example/baton-test-app", + OutputDir: outputDir, + Description: "Test connector for testing", + } + + if err := Generate(cfg); err != nil { + t.Fatalf("Generate failed: %v", err) + } + + // Verify expected files exist + // These match the files generated by scaffold.Generate + // Note: connectors sync three fundamental resource types: + // - users (principals that receive grants) + // - groups (with "member" entitlement) + // - roles (with "assigned" entitlement) + expectedFiles := []string{ + "go.mod", + "main.go", + "pkg/connector/connector.go", + "pkg/connector/resource_types.go", + "pkg/connector/users.go", + "pkg/connector/groups.go", + "pkg/connector/roles.go", + "pkg/connector/actions.go", + "pkg/client/client.go", + "docs/README.md", + "docs/API_NOTES.md", + "docs/.gitignore", + ".gitignore", + ".golangci.yml", + ".github/workflows/ci.yaml", + ".github/workflows/release.yaml", + "README.md", + "Makefile", + "CLAUDE.md", + "Dockerfile", + "Dockerfile.lambda", + "docker-compose.yml", + } + + for _, f := range expectedFiles { + path := filepath.Join(outputDir, f) + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Errorf("expected file %s to exist", f) + } + } + + // Verify go.mod contains module path + goMod, err := os.ReadFile(filepath.Join(outputDir, "go.mod")) + if err != nil { + t.Fatalf("failed to read go.mod: %v", err) + } + if !strings.Contains(string(goMod), "github.com/example/baton-test-app") { + t.Error("go.mod should contain module path") + } + + // Verify main.go contains connector name + mainGo, err := os.ReadFile(filepath.Join(outputDir, "main.go")) + if err != nil { + t.Fatalf("failed to read main.go: %v", err) + } + if !strings.Contains(string(mainGo), "baton-test-app") { + t.Error("main.go should contain connector name") + } +} + +func TestGenerateDefaults(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "scaffold-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + // Change to temp dir so default output dir works + oldWd, _ := os.Getwd() + os.Chdir(tmpDir) + defer os.Chdir(oldWd) + + cfg := &Config{ + Name: "my-service", + } + + if err := Generate(cfg); err != nil { + t.Fatalf("Generate failed: %v", err) + } + + // Verify defaults were applied + expectedDir := filepath.Join(tmpDir, "baton-my-service") + if _, err := os.Stat(expectedDir); os.IsNotExist(err) { + t.Error("expected default output directory baton-my-service") + } + + // Verify default module path + goMod, err := os.ReadFile(filepath.Join(expectedDir, "go.mod")) + if err != nil { + t.Fatalf("failed to read go.mod: %v", err) + } + if !strings.Contains(string(goMod), "github.com/conductorone/baton-my-service") { + t.Error("go.mod should contain default module path") + } +} + +func TestGenerateMissingName(t *testing.T) { + cfg := &Config{} + + err := Generate(cfg) + if err == nil { + t.Error("expected error for missing name") + } +} + +func TestToPascalCase(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"my-app", "MyApp"}, + {"test", "Test"}, + {"foo-bar-baz", "FooBarBaz"}, + {"", ""}, + } + + for _, tc := range tests { + result := toPascalCase(tc.input) + if result != tc.expected { + t.Errorf("toPascalCase(%q) = %q, expected %q", tc.input, result, tc.expected) + } + } +} + +// TestGenerateCompiles verifies that generated code actually compiles. +// This is a CRITICAL test - without it, templates can drift from working code +// and remain broken silently. This catches SDK API changes, import errors, etc. +// +// This test requires network access for go mod download. +// Set SKIP_COMPILE_TEST=1 to skip in offline CI environments. +func TestGenerateCompiles(t *testing.T) { + if testing.Short() { + t.Skip("skipping compilation test in short mode") + } + + if os.Getenv("SKIP_COMPILE_TEST") != "" { + t.Skip("skipping compilation test: SKIP_COMPILE_TEST is set") + } + + tmpDir, err := os.MkdirTemp("", "scaffold-compile-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + outputDir := filepath.Join(tmpDir, "baton-compile-test") + + cfg := &Config{ + Name: "compile-test", + ModulePath: "github.com/example/baton-compile-test", + OutputDir: outputDir, + Description: "Test connector to verify compilation", + } + + if err := Generate(cfg); err != nil { + t.Fatalf("Generate failed: %v", err) + } + + // Run go mod tidy to download dependencies + runCommand(t, outputDir, 5*time.Minute, "go", "mod", "tidy") + + // Run go build to verify compilation + runCommand(t, outputDir, 3*time.Minute, "go", "build", "-o", "/dev/null", ".") + + t.Log("Generated code compiles successfully") +} + +// TestGenerateVet runs go vet on generated code. +// This catches common issues like unreachable code, shadow variables, etc. +func TestGenerateVet(t *testing.T) { + if testing.Short() { + t.Skip("skipping vet test in short mode") + } + + if os.Getenv("SKIP_COMPILE_TEST") != "" { + t.Skip("skipping vet test: SKIP_COMPILE_TEST is set") + } + + tmpDir, err := os.MkdirTemp("", "scaffold-vet-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + outputDir := filepath.Join(tmpDir, "baton-vet-test") + + cfg := &Config{ + Name: "vet-test", + ModulePath: "github.com/example/baton-vet-test", + OutputDir: outputDir, + Description: "Test connector to verify go vet passes", + } + + if err := Generate(cfg); err != nil { + t.Fatalf("Generate failed: %v", err) + } + + // Run go mod tidy first + runCommand(t, outputDir, 5*time.Minute, "go", "mod", "tidy") + + // Run go vet + runCommand(t, outputDir, 2*time.Minute, "go", "vet", "./...") + + t.Log("Generated code passes go vet") +} + +// runCommand runs a command with timeout and fails the test on error. +func runCommand(t *testing.T, dir string, timeout time.Duration, name string, args ...string) { + t.Helper() + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + cmd := exec.CommandContext(ctx, name, args...) + cmd.Dir = dir + + output, err := cmd.CombinedOutput() + if ctx.Err() == context.DeadlineExceeded { + t.Fatalf("%s timed out after %v", name, timeout) + } + if err != nil { + t.Fatalf("%s %v failed: %v\nOutput:\n%s", name, args, err, string(output)) + } +}