diff --git a/.env.test.example b/.env.test.example new file mode 100644 index 0000000..5f24d5f --- /dev/null +++ b/.env.test.example @@ -0,0 +1,3 @@ +# Test environment configuration +TEST_API_BASE_URL=http://localhost:8010 +TEST_API_TOKEN=your_posthog_api_token_here \ No newline at end of file diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 799031b..3f863a7 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -14,8 +14,6 @@ jobs: - name: Install pnpm uses: pnpm/action-setup@v4 - with: - version: 9 - name: Setup Node.js uses: actions/setup-node@v4 diff --git a/.gitignore b/.gitignore index 6979ab3..5cdbff1 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules .wrangler .dev.vars* .mcp.json +.env.test # Editor-specific files .cursor/ diff --git a/CLAUDE.md b/CLAUDE.md index 908fadb..8df8721 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,10 +17,11 @@ This is a PostHog MCP (Model Context Protocol) server built on Cloudflare Worker ### Key Components - **Main MCP Class (`src/index.ts`)**: `MyMCP` extends `McpAgent` and defines all available tools for interacting with PostHog -- **API Layer (`src/posthogApi.ts`)**: Functions for making requests to PostHog's REST API +- **Unified API Client (`src/api/client.ts`)**: `ApiClient` class provides type-safe methods for all PostHog API interactions with proper error handling and schema validation - **Schema Validation (`src/schema/`)**: Zod schemas for validating API requests and responses - **Caching (`src/lib/utils/cache/`)**: User-scoped memory cache for storing project/org state - **Documentation Search (`src/inkeepApi.ts`)**: Integration with Inkeep for PostHog docs search +- **Utility Functions (`src/lib/utils/api.ts`)**: Helper functions for pagination and URL generation ### Authentication & State Management @@ -29,19 +30,38 @@ This is a PostHog MCP (Model Context Protocol) server built on Cloudflare Worker - Automatic project/org selection when user has only one option - State persists across requests within the same session +### API Architecture + +The codebase uses a unified API client pattern: + +- **API Client (`ApiClient`)**: Central class that handles all PostHog API requests with consistent error handling, authentication, and response validation +- **Resource Methods**: API client is organized into resource-based methods: + - `organizations()`: Organization CRUD and project listing + - `projects()`: Project details and property definitions + - `featureFlags()`: Feature flag CRUD operations + - `insights()`: Insight CRUD, listing, and SQL queries + - `dashboards()`: Dashboard CRUD and insight management + - `query()`: Generic query execution for analytics + - `users()`: User information and authentication +- **Type Safety**: All methods return `Result` types with proper TypeScript definitions +- **URL Generation**: Uses configurable `BASE_URL` from constants for environment-aware URLs (localhost in dev, PostHog production in prod) + ### Tool Categories 1. **Organization/Project Management**: Get orgs, projects, set active context -2. **Feature Flags**: CRUD operations on feature flags -3. **Error Tracking**: Query errors and error details -4. **Data Warehouse**: SQL insights via natural language queries -5. **Documentation**: Search PostHog docs via Inkeep API -6. **Analytics**: LLM cost tracking and other metrics +2. **Feature Flags**: CRUD operations on feature flags +3. **Insights & Dashboards**: CRUD operations on insights and dashboards with proper URL generation +4. **Error Tracking**: Query errors and error details +5. **Data Warehouse**: SQL insights via natural language queries +6. **Documentation**: Search PostHog docs via Inkeep API +7. **Analytics**: LLM cost tracking and other metrics ### Environment Setup - Create `.dev.vars` file with `INKEEP_API_KEY` for docs search functionality - API token passed via Authorization header from MCP client configuration +- **Development Mode**: Set `DEV = true` in `src/lib/constants.ts` to use `http://localhost:8010` for API calls and URLs +- **Production Mode**: Set `DEV = false` to use `https://us.posthog.com` for API calls and URLs ### Code Style diff --git a/package.json b/package.json index 20638d2..978378a 100644 --- a/package.json +++ b/package.json @@ -7,21 +7,27 @@ "dev": "wrangler dev", "format": "biome format --write", "lint:fix": "biome lint --fix", + "test": "vitest", + "test:watch": "vitest watch", "start": "wrangler dev", "cf-typegen": "wrangler types", "prepare": "husky" }, "devDependencies": { "@biomejs/biome": "1.9.4", - "@types/node": "^22.15.18", + "@types/node": "^22.15.34", + "dotenv": "^16.4.7", "husky": "^9.1.7", "typescript": "^5.8.3", + "vitest": "^3.2.4", "wrangler": "^4.14.4" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.11.1", + "@types/dotenv": "^6.1.1", "agents": "^0.0.80", "posthog-node": "^4.18.0", "zod": "^3.24.4" - } + }, + "packageManager": "pnpm@9.15.5+sha256.8472168c3e1fd0bff287e694b053fccbbf20579a3ff9526b6333beab8df65a8d" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb3558f..bca1705 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@modelcontextprotocol/sdk': specifier: ^1.11.1 version: 1.12.0 + '@types/dotenv': + specifier: ^6.1.1 + version: 6.1.1 agents: specifier: ^0.0.80 version: 0.0.80(@cloudflare/workers-types@4.20250525.0)(react@19.1.0) @@ -25,14 +28,20 @@ importers: specifier: 1.9.4 version: 1.9.4 '@types/node': - specifier: ^22.15.18 - version: 22.15.21 + specifier: ^22.15.34 + version: 22.16.5 + dotenv: + specifier: ^16.4.7 + version: 16.6.1 husky: specifier: ^9.1.7 version: 9.1.7 typescript: specifier: ^5.8.3 version: 5.8.3 + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/node@22.16.5) wrangler: specifier: ^4.14.4 version: 4.16.1(@cloudflare/workers-types@4.20250525.0) @@ -448,11 +457,152 @@ packages: resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} + '@rollup/rollup-android-arm-eabi@4.45.1': + resolution: {integrity: sha512-NEySIFvMY0ZQO+utJkgoMiCAjMrGvnbDLHvcmlA33UXJpYBCvlBEbMMtV837uCkS+plG2umfhn0T5mMAxGrlRA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.45.1': + resolution: {integrity: sha512-ujQ+sMXJkg4LRJaYreaVx7Z/VMgBBd89wGS4qMrdtfUFZ+TSY5Rs9asgjitLwzeIbhwdEhyj29zhst3L1lKsRQ==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.45.1': + resolution: {integrity: sha512-FSncqHvqTm3lC6Y13xncsdOYfxGSLnP+73k815EfNmpewPs+EyM49haPS105Rh4aF5mJKywk9X0ogzLXZzN9lA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.45.1': + resolution: {integrity: sha512-2/vVn/husP5XI7Fsf/RlhDaQJ7x9zjvC81anIVbr4b/f0xtSmXQTFcGIQ/B1cXIYM6h2nAhJkdMHTnD7OtQ9Og==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.45.1': + resolution: {integrity: sha512-4g1kaDxQItZsrkVTdYQ0bxu4ZIQ32cotoQbmsAnW1jAE4XCMbcBPDirX5fyUzdhVCKgPcrwWuucI8yrVRBw2+g==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.45.1': + resolution: {integrity: sha512-L/6JsfiL74i3uK1Ti2ZFSNsp5NMiM4/kbbGEcOCps99aZx3g8SJMO1/9Y0n/qKlWZfn6sScf98lEOUe2mBvW9A==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.45.1': + resolution: {integrity: sha512-RkdOTu2jK7brlu+ZwjMIZfdV2sSYHK2qR08FUWcIoqJC2eywHbXr0L8T/pONFwkGukQqERDheaGTeedG+rra6Q==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.45.1': + resolution: {integrity: sha512-3kJ8pgfBt6CIIr1o+HQA7OZ9mp/zDk3ctekGl9qn/pRBgrRgfwiffaUmqioUGN9hv0OHv2gxmvdKOkARCtRb8Q==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.45.1': + resolution: {integrity: sha512-k3dOKCfIVixWjG7OXTCOmDfJj3vbdhN0QYEqB+OuGArOChek22hn7Uy5A/gTDNAcCy5v2YcXRJ/Qcnm4/ma1xw==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.45.1': + resolution: {integrity: sha512-PmI1vxQetnM58ZmDFl9/Uk2lpBBby6B6rF4muJc65uZbxCs0EA7hhKCk2PKlmZKuyVSHAyIw3+/SiuMLxKxWog==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loongarch64-gnu@4.45.1': + resolution: {integrity: sha512-9UmI0VzGmNJ28ibHW2GpE2nF0PBQqsyiS4kcJ5vK+wuwGnV5RlqdczVocDSUfGX/Na7/XINRVoUgJyFIgipoRg==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-powerpc64le-gnu@4.45.1': + resolution: {integrity: sha512-7nR2KY8oEOUTD3pBAxIBBbZr0U7U+R9HDTPNy+5nVVHDXI4ikYniH1oxQz9VoB5PbBU1CZuDGHkLJkd3zLMWsg==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.45.1': + resolution: {integrity: sha512-nlcl3jgUultKROfZijKjRQLUu9Ma0PeNv/VFHkZiKbXTBQXhpytS8CIj5/NfBeECZtY2FJQubm6ltIxm/ftxpw==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.45.1': + resolution: {integrity: sha512-HJV65KLS51rW0VY6rvZkiieiBnurSzpzore1bMKAhunQiECPuxsROvyeaot/tcK3A3aGnI+qTHqisrpSgQrpgA==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.45.1': + resolution: {integrity: sha512-NITBOCv3Qqc6hhwFt7jLV78VEO/il4YcBzoMGGNxznLgRQf43VQDae0aAzKiBeEPIxnDrACiMgbqjuihx08OOw==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.45.1': + resolution: {integrity: sha512-+E/lYl6qu1zqgPEnTrs4WysQtvc/Sh4fC2nByfFExqgYrqkKWp1tWIbe+ELhixnenSpBbLXNi6vbEEJ8M7fiHw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.45.1': + resolution: {integrity: sha512-a6WIAp89p3kpNoYStITT9RbTbTnqarU7D8N8F2CV+4Cl9fwCOZraLVuVFvlpsW0SbIiYtEnhCZBPLoNdRkjQFw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-win32-arm64-msvc@4.45.1': + resolution: {integrity: sha512-T5Bi/NS3fQiJeYdGvRpTAP5P02kqSOpqiopwhj0uaXB6nzs5JVi2XMJb18JUSKhCOX8+UE1UKQufyD6Or48dJg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.45.1': + resolution: {integrity: sha512-lxV2Pako3ujjuUe9jiU3/s7KSrDfH6IgTSQOnDWr9aJ92YsFd7EurmClK0ly/t8dzMkDtd04g60WX6yl0sGfdw==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.45.1': + resolution: {integrity: sha512-M/fKi4sasCdM8i0aWJjCSFm2qEnYRR8AMLG2kxp6wD13+tMGA4Z1tVAuHkNRjud5SW2EM3naLuK35w9twvf6aA==} + cpu: [x64] + os: [win32] + + '@types/chai@5.2.2': + resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/diff-match-patch@1.0.36': resolution: {integrity: sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==} - '@types/node@22.15.21': - resolution: {integrity: sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ==} + '@types/dotenv@6.1.1': + resolution: {integrity: sha512-ftQl3DtBvqHl9L16tpqqzA4YzCSXZfi7g8cQceTz5rOlYtk/IZbFjAv3mLOQlNIgOaylCQWQoBdDQHPgEBJPHg==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/node@22.16.5': + resolution: {integrity: sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ==} + + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} @@ -488,6 +638,10 @@ packages: as-table@1.0.55: resolution: {integrity: sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -505,6 +659,10 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -513,10 +671,18 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} + chai@5.2.1: + resolution: {integrity: sha512-5nFxhUrX0PqtyogoYOA8IPswy5sZFTOsBFl/9bNsmDLgsxYTzSZQJDPppDnZPTQbzSEm0hqGjWPzRemQCYbD6A==} + engines: {node: '>=18'} + chalk@5.4.1: resolution: {integrity: sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + check-error@2.1.1: + resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} + engines: {node: '>= 16'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -575,6 +741,10 @@ packages: supports-color: optional: true + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} @@ -597,6 +767,10 @@ packages: diff-match-patch@1.0.5: resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==} + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -616,6 +790,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -632,6 +809,9 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + etag@1.8.1: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} @@ -651,6 +831,10 @@ packages: resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==} engines: {node: '>=6'} + expect-type@1.2.2: + resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} + engines: {node: '>=12.0.0'} + express-rate-limit@7.5.0: resolution: {integrity: sha512-eB5zbQh5h+VenMPM3fh+nw1YExi5nMr6HUCR62ELSP11huvxm/Uir1H1QEyTkk5QX6A58pX6NmaTMceKZ0Eodg==} engines: {node: '>= 16'} @@ -670,6 +854,14 @@ packages: fast-json-stable-stringify@2.1.0: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + fdir@6.4.6: + resolution: {integrity: sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + finalhandler@2.1.0: resolution: {integrity: sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==} engines: {node: '>= 0.8'} @@ -762,6 +954,9 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -773,6 +968,12 @@ packages: engines: {node: ^18.0.0 || >=20.0.0} hasBin: true + loupe@3.1.4: + resolution: {integrity: sha512-wJzkKwJrheKtknCOKNEtDK4iqg/MxmZheEMtSTYvnzRdEYaZzmgH976nenp8WdJRdx5Vc1X/9MO0Oszl6ezeXg==} + + magic-string@0.30.17: + resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -876,10 +1077,25 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + pkce-challenge@5.0.0: resolution: {integrity: sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==} engines: {node: '>=16.20.0'} + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + posthog-node@4.18.0: resolution: {integrity: sha512-XROs1h+DNatgKh/AlIlCtDxWzwrKdYDb2mOs58n4yN8BkGN9ewqeQwG5ApS4/IzwCb7HPttUkOVulkYatd2PIw==} engines: {node: '>=15.0.0'} @@ -914,6 +1130,11 @@ packages: resolution: {integrity: sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==} engines: {node: '>=0.10.0'} + rollup@4.45.1: + resolution: {integrity: sha512-4iya7Jb76fVpQyLoiVpzUrsjQ12r3dM7fIVz+4NwoYvZOShknRmiv+iu9CClZml5ZLGb0XMcYLutK6w9tgxHDw==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -971,13 +1192,23 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + simple-swizzle@0.2.2: resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + source-map@0.6.1: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + stacktracey@2.1.8: resolution: {integrity: sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw==} @@ -985,10 +1216,16 @@ packages: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} + std-env@3.9.0: + resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} + stoppable@1.1.0: resolution: {integrity: sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==} engines: {node: '>=4', npm: '>=6'} + strip-literal@3.0.0: + resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==} + swr@2.3.3: resolution: {integrity: sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A==} peerDependencies: @@ -998,6 +1235,28 @@ packages: resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==} engines: {node: '>=18'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.14: + resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.3: + resolution: {integrity: sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A==} + engines: {node: '>=14.0.0'} + toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -1043,11 +1302,89 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.0.5: + resolution: {integrity: sha512-1mncVwJxy2C9ThLwz0+2GKZyEXuC3MyWtAAlNftlZZXZDP3AJt5FmwcMit/IGGaNZ8ZOB2BNO/HFUB+CpN0NQw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + workerd@1.20250508.0: resolution: {integrity: sha512-ffLxe7dXSuGoA6jb3Qx2SClIV1aLHfJQ6RhGhzYHjQgv7dL6fdUOSIIGgzmu2mRKs+WFSujp6c8WgKquco6w3w==} engines: {node: '>=16'} @@ -1372,12 +1709,126 @@ snapshots: '@opentelemetry/api@1.9.0': {} + '@rollup/rollup-android-arm-eabi@4.45.1': + optional: true + + '@rollup/rollup-android-arm64@4.45.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.45.1': + optional: true + + '@rollup/rollup-darwin-x64@4.45.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.45.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.45.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.45.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.45.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.45.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.45.1': + optional: true + + '@rollup/rollup-linux-loongarch64-gnu@4.45.1': + optional: true + + '@rollup/rollup-linux-powerpc64le-gnu@4.45.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.45.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.45.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.45.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.45.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.45.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.45.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.45.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.45.1': + optional: true + + '@types/chai@5.2.2': + dependencies: + '@types/deep-eql': 4.0.2 + + '@types/deep-eql@4.0.2': {} + '@types/diff-match-patch@1.0.36': {} - '@types/node@22.15.21': + '@types/dotenv@6.1.1': + dependencies: + '@types/node': 22.16.5 + + '@types/estree@1.0.8': {} + + '@types/node@22.16.5': dependencies: undici-types: 6.21.0 + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.2 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.2.1 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.4(vite@7.0.5(@types/node@22.16.5))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.17 + optionalDependencies: + vite: 7.0.5(@types/node@22.16.5) + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.0.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.17 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.3 + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.1.4 + tinyrainbow: 2.0.0 + accepts@2.0.0: dependencies: mime-types: 3.0.1 @@ -1424,6 +1875,8 @@ snapshots: dependencies: printable-characters: 1.0.42 + assertion-error@2.0.1: {} + asynckit@0.4.0: {} axios@1.9.0: @@ -1452,6 +1905,8 @@ snapshots: bytes@3.1.2: {} + cac@6.7.14: {} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -1462,8 +1917,18 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 + chai@5.2.1: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.1 + deep-eql: 5.0.2 + loupe: 3.1.4 + pathval: 2.0.1 + chalk@5.4.1: {} + check-error@2.1.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -1513,6 +1978,8 @@ snapshots: dependencies: ms: 2.1.3 + deep-eql@5.0.2: {} + defu@6.1.4: {} delayed-stream@1.0.0: {} @@ -1525,6 +1992,8 @@ snapshots: diff-match-patch@1.0.5: {} + dotenv@16.6.1: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -1539,6 +2008,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -1580,6 +2051,10 @@ snapshots: escape-html@1.0.3: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + etag@1.8.1: {} event-target-polyfill@0.0.4: {} @@ -1592,6 +2067,8 @@ snapshots: exit-hook@2.2.1: {} + expect-type@1.2.2: {} + express-rate-limit@7.5.0(express@5.1.0): dependencies: express: 5.1.0 @@ -1634,6 +2111,10 @@ snapshots: fast-json-stable-stringify@2.1.0: {} + fdir@6.4.6(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + finalhandler@2.1.0: dependencies: debug: 4.4.1 @@ -1724,6 +2205,8 @@ snapshots: isexe@2.0.0: {} + js-tokens@9.0.1: {} + json-schema-traverse@0.4.1: {} json-schema@0.4.0: {} @@ -1734,6 +2217,12 @@ snapshots: chalk: 5.4.1 diff-match-patch: 1.0.5 + loupe@3.1.4: {} + + magic-string@0.30.17: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.0 + math-intrinsics@1.1.0: {} media-typer@1.1.0: {} @@ -1815,8 +2304,20 @@ snapshots: pathe@2.0.3: {} + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.3: {} + pkce-challenge@5.0.0: {} + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + posthog-node@4.18.0: dependencies: axios: 1.9.0 @@ -1849,6 +2350,32 @@ snapshots: react@19.1.0: {} + rollup@4.45.1: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.45.1 + '@rollup/rollup-android-arm64': 4.45.1 + '@rollup/rollup-darwin-arm64': 4.45.1 + '@rollup/rollup-darwin-x64': 4.45.1 + '@rollup/rollup-freebsd-arm64': 4.45.1 + '@rollup/rollup-freebsd-x64': 4.45.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.45.1 + '@rollup/rollup-linux-arm-musleabihf': 4.45.1 + '@rollup/rollup-linux-arm64-gnu': 4.45.1 + '@rollup/rollup-linux-arm64-musl': 4.45.1 + '@rollup/rollup-linux-loongarch64-gnu': 4.45.1 + '@rollup/rollup-linux-powerpc64le-gnu': 4.45.1 + '@rollup/rollup-linux-riscv64-gnu': 4.45.1 + '@rollup/rollup-linux-riscv64-musl': 4.45.1 + '@rollup/rollup-linux-s390x-gnu': 4.45.1 + '@rollup/rollup-linux-x64-gnu': 4.45.1 + '@rollup/rollup-linux-x64-musl': 4.45.1 + '@rollup/rollup-win32-arm64-msvc': 4.45.1 + '@rollup/rollup-win32-ia32-msvc': 4.45.1 + '@rollup/rollup-win32-x64-msvc': 4.45.1 + fsevents: 2.3.3 + router@2.2.0: dependencies: debug: 4.4.1 @@ -1954,12 +2481,18 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + simple-swizzle@0.2.2: dependencies: is-arrayish: 0.3.2 + source-map-js@1.2.1: {} + source-map@0.6.1: {} + stackback@0.0.2: {} + stacktracey@2.1.8: dependencies: as-table: 1.0.55 @@ -1967,8 +2500,14 @@ snapshots: statuses@2.0.1: {} + std-env@3.9.0: {} + stoppable@1.1.0: {} + strip-literal@3.0.0: + dependencies: + js-tokens: 9.0.1 + swr@2.3.3(react@19.1.0): dependencies: dequal: 2.0.3 @@ -1977,6 +2516,21 @@ snapshots: throttleit@2.1.0: {} + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.14: + dependencies: + fdir: 6.4.6(picomatch@4.0.3) + picomatch: 4.0.3 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.3: {} + toidentifier@1.0.1: {} tslib@2.8.1: @@ -2018,10 +2572,89 @@ snapshots: vary@1.1.2: {} + vite-node@3.2.4(@types/node@22.16.5): + dependencies: + cac: 6.7.14 + debug: 4.4.1 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.0.5(@types/node@22.16.5) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.0.5(@types/node@22.16.5): + dependencies: + esbuild: 0.25.4 + fdir: 6.4.6(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.45.1 + tinyglobby: 0.2.14 + optionalDependencies: + '@types/node': 22.16.5 + fsevents: 2.3.3 + + vitest@3.2.4(@types/node@22.16.5): + dependencies: + '@types/chai': 5.2.2 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.0.5(@types/node@22.16.5)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.2.1 + debug: 4.4.1 + expect-type: 1.2.2 + magic-string: 0.30.17 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.9.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.14 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.0.5(@types/node@22.16.5) + vite-node: 3.2.4(@types/node@22.16.5) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.16.5 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + which@2.0.2: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + workerd@1.20250508.0: optionalDependencies: '@cloudflare/workerd-darwin-64': 1.20250508.0 diff --git a/src/api/client.ts b/src/api/client.ts new file mode 100644 index 0000000..a75730c --- /dev/null +++ b/src/api/client.ts @@ -0,0 +1,744 @@ +import { BASE_URL } from "../lib/constants"; +import { ErrorCode } from "../lib/errors"; +import { withPagination } from "../lib/utils/api"; +import { OrganizationSchema, type Organization } from "../schema/orgs"; +import { ProjectSchema, type Project } from "../schema/projects"; +import { + type CreateFeatureFlagInput, + CreateFeatureFlagInputSchema, + type UpdateFeatureFlagInput, + UpdateFeatureFlagInputSchema, + FeatureFlagSchema, +} from "../schema/flags"; +import { ApiPropertyDefinitionSchema } from "../schema/api"; +import { PropertyDefinitionSchema } from "../schema/properties"; +import { + type CreateInsightInput, + CreateInsightInputSchema, + type ListInsightsData, + ListInsightsSchema, +} from "../schema/insights"; +import { + type CreateDashboardInput, + CreateDashboardInputSchema, + type ListDashboardsData, + ListDashboardsSchema, +} from "../schema/dashboards"; +import { z } from "zod"; + +export type Result = { success: true; data: T } | { success: false; error: E }; + +export interface ApiConfig { + apiToken: string; + baseUrl?: string; +} +export class ApiClient { + private config: ApiConfig; + private baseUrl: string; + + constructor(config: ApiConfig) { + this.config = config; + this.baseUrl = config.baseUrl || BASE_URL; + } + private buildHeaders() { + return { + Authorization: `Bearer ${this.config.apiToken}`, + "Content-Type": "application/json", + }; + } + + private async fetchWithSchema( + url: string, + schema: z.ZodType, + options?: RequestInit, + ): Promise> { + try { + const response = await fetch(url, { + ...options, + headers: { + ...this.buildHeaders(), + ...options?.headers, + }, + }); + + if (!response.ok) { + if (response.status === 401) { + throw new Error(ErrorCode.INVALID_API_KEY); + } + + try { + const errorData = (await response.json()) as any; + if (errorData.type === "validation_error" && errorData.code) { + throw new Error(`Validation error: ${errorData.code}`); + } + } catch {} + + throw new Error(`Request failed: ${response.statusText}`); + } + + const rawData = await response.json(); + const parseResult = schema.safeParse(rawData); + + if (!parseResult.success) { + throw new Error(`Response validation failed: ${parseResult.error.message}`); + } + + return { success: true, data: parseResult.data }; + } catch (error) { + return { success: false, error: error as Error }; + } + } + + organizations() { + return { + list: async (): Promise> => { + const responseSchema = z.object({ + results: z.array(OrganizationSchema), + }); + + const result = await this.fetchWithSchema( + `${this.baseUrl}/api/organizations/`, + responseSchema, + ); + + if (result.success) { + return { success: true, data: result.data.results }; + } + return result; + }, + + get: async ({ orgId }: { orgId: string }): Promise> => { + return this.fetchWithSchema( + `${this.baseUrl}/api/organizations/${orgId}/`, + OrganizationSchema, + ); + }, + + projects: ({ orgId }: { orgId: string }) => { + return { + list: async (): Promise> => { + const responseSchema = z.object({ + results: z.array(ProjectSchema), + }); + + const result = await this.fetchWithSchema( + `${this.baseUrl}/api/organizations/${orgId}/projects/`, + responseSchema, + ); + + if (result.success) { + return { success: true, data: result.data.results }; + } + return result; + }, + }; + }, + }; + } + + projects() { + return { + get: async ({ projectId }: { projectId: string }): Promise> => { + return this.fetchWithSchema( + `${this.baseUrl}/api/projects/${projectId}/`, + ProjectSchema, + ); + }, + + propertyDefinitions: async ({ + projectId, + }: { projectId: string }): Promise> => { + try { + const propertyDefinitions = await withPagination( + `${this.baseUrl}/api/projects/${projectId}/property_definitions/`, + this.config.apiToken, + ApiPropertyDefinitionSchema, + ); + + const propertyDefinitionsWithoutHidden = propertyDefinitions.filter( + (def) => !def.hidden, + ); + + const validated = propertyDefinitionsWithoutHidden.map((def) => + PropertyDefinitionSchema.parse(def), + ); + + return { success: true, data: validated }; + } catch (error) { + return { success: false, error: error as Error }; + } + }, + }; + } + + featureFlags({ projectId }: { projectId: string }) { + return { + list: async (): Promise< + Result> + > => { + try { + const schema = FeatureFlagSchema.pick({ + id: true, + key: true, + name: true, + active: true, + }); + + const response = await withPagination( + `${this.baseUrl}/api/projects/${projectId}/feature_flags/`, + this.config.apiToken, + schema, + ); + + return { + success: true, + data: response as Array<{ + id: number; + key: string; + name: string; + active: boolean; + }>, + }; + } catch (error) { + return { success: false, error: error as Error }; + } + }, + + get: async ({ + flagId, + }: { flagId: string | number }): Promise< + Result<{ + id: number; + key: string; + name: string; + active: boolean; + description?: string; + }> + > => { + return this.fetchWithSchema( + `${this.baseUrl}/api/projects/${projectId}/feature_flags/${flagId}/`, + FeatureFlagSchema.pick({ + id: true, + key: true, + name: true, + active: true, + description: true, + }), + ); + }, + + findByKey: async ({ + key, + }: { key: string }): Promise< + Result<{ id: number; key: string; name: string; active: boolean } | undefined> + > => { + const listResult = await this.featureFlags({ projectId }).list(); + + if (!listResult.success) { + return { success: false, error: listResult.error }; + } + + const found = listResult.data.find((f) => f.key === key); + return { success: true, data: found }; + }, + + create: async ({ + data, + }: { data: CreateFeatureFlagInput }): Promise< + Result<{ id: number; key: string; name: string; active: boolean }> + > => { + const validatedInput = CreateFeatureFlagInputSchema.parse(data); + + const body = { + key: validatedInput.key, + name: validatedInput.name, + description: validatedInput.description, + active: validatedInput.active, + filters: validatedInput.filters, + }; + + return this.fetchWithSchema( + `${this.baseUrl}/api/projects/${projectId}/feature_flags/`, + FeatureFlagSchema.pick({ + id: true, + key: true, + name: true, + active: true, + }), + { + method: "POST", + body: JSON.stringify(body), + }, + ); + }, + + update: async ({ + key, + data, + }: { key: string; data: UpdateFeatureFlagInput }): Promise< + Result<{ id: number; key: string; name: string; active: boolean }> + > => { + const validatedInput = UpdateFeatureFlagInputSchema.parse(data); + const findResult = await this.featureFlags({ projectId }).findByKey({ key }); + + if (!findResult.success) { + return findResult; + } + + if (!findResult.data) { + return { + success: false, + error: new Error(`Feature flag not found: ${key}`), + }; + } + + const body = { + key: key, + name: validatedInput.name, + description: validatedInput.description, + active: validatedInput.active, + filters: validatedInput.filters, + }; + + return this.fetchWithSchema( + `${this.baseUrl}/api/projects/${projectId}/feature_flags/${findResult.data.id}/`, + FeatureFlagSchema.pick({ + id: true, + key: true, + name: true, + active: true, + }), + { + method: "PATCH", + body: JSON.stringify(body), + }, + ); + }, + + delete: async ({ + flagId, + }: { flagId: number }): Promise> => { + try { + const response = await fetch( + `${this.baseUrl}/api/projects/${projectId}/feature_flags/${flagId}/`, + { + method: "PATCH", + headers: this.buildHeaders(), + body: JSON.stringify({ deleted: true }), + }, + ); + + if (!response.ok) { + throw new Error(`Failed to delete feature flag: ${response.statusText}`); + } + + return { + success: true, + data: { + success: true, + message: "Feature flag deleted successfully", + }, + }; + } catch (error) { + return { success: false, error: error as Error }; + } + }, + }; + } + + insights({ projectId }: { projectId: string }) { + return { + list: async ({ + params, + }: { params?: ListInsightsData } = {}): Promise< + Result< + Array<{ + id: number; + name: string; + short_id: string; + description?: string | null; + }> + > + > => { + const validatedParams = params ? ListInsightsSchema.parse(params) : undefined; + const searchParams = new URLSearchParams(); + + if (validatedParams?.limit) + searchParams.append("limit", String(validatedParams.limit)); + if (validatedParams?.offset) + searchParams.append("offset", String(validatedParams.offset)); + if (validatedParams?.saved !== undefined) + searchParams.append("saved", String(validatedParams.saved)); + if (validatedParams?.search) searchParams.append("search", validatedParams.search); + + const url = `${this.baseUrl}/api/projects/${projectId}/insights/${searchParams.toString() ? `?${searchParams}` : ""}`; + + const simpleInsightSchema = z.object({ + id: z.number(), + name: z.string(), + short_id: z.string(), + description: z.string().optional().nullable(), + }); + + const responseSchema = z.object({ + results: z.array(simpleInsightSchema), + }); + + const result = await this.fetchWithSchema(url, responseSchema); + if (result.success) { + return { success: true, data: result.data.results }; + } + return result; + }, + + create: async ({ + data, + }: { data: CreateInsightInput }): Promise< + Result<{ id: number; name: string; short_id: string }> + > => { + const validatedInput = CreateInsightInputSchema.parse(data); + + const createResponseSchema = z.object({ + id: z.number(), + name: z.string(), + short_id: z.string(), + }); + + return this.fetchWithSchema( + `${this.baseUrl}/api/projects/${projectId}/insights/`, + createResponseSchema, + { + method: "POST", + body: JSON.stringify(validatedInput), + }, + ); + }, + + get: async ({ + insightId, + }: { insightId: number }): Promise< + Result<{ id: number; name: string; short_id: string; description?: string | null }> + > => { + const simpleInsightSchema = z.object({ + id: z.number(), + name: z.string(), + short_id: z.string(), + description: z.string().nullable().optional(), + }); + + return this.fetchWithSchema( + `${this.baseUrl}/api/projects/${projectId}/insights/${insightId}/`, + simpleInsightSchema, + ); + }, + + update: async ({ + insightId, + data, + }: { insightId: number; data: any }): Promise< + Result<{ id: number; name: string; short_id: string }> + > => { + const updateResponseSchema = z.object({ + id: z.number(), + name: z.string(), + short_id: z.string(), + }); + + return this.fetchWithSchema( + `${this.baseUrl}/api/projects/${projectId}/insights/${insightId}/`, + updateResponseSchema, + { + method: "PATCH", + body: JSON.stringify(data), + }, + ); + }, + + delete: async ({ + insightId, + }: { insightId: number }): Promise> => { + try { + const response = await fetch( + `${this.baseUrl}/api/projects/${projectId}/insights/${insightId}/`, + { + method: "PATCH", + headers: this.buildHeaders(), + body: JSON.stringify({ deleted: true }), + }, + ); + + if (!response.ok) { + throw new Error(`Failed to delete insight: ${response.statusText}`); + } + + return { + success: true, + data: { + success: true, + message: "Insight deleted successfully", + }, + }; + } catch (error) { + return { success: false, error: error as Error }; + } + }, + + sqlInsight: async ({ + query, + }: { query: string }): Promise> => { + const requestBody = { + query: query, + insight_type: "sql", + }; + + const sqlResponseSchema = z.array(z.any()); + + const result = await this.fetchWithSchema( + `${this.baseUrl}/api/environments/${projectId}/max_tools/create_and_query_insight/`, + sqlResponseSchema, + { + method: "POST", + body: JSON.stringify(requestBody), + }, + ); + + if (result.success) { + const dataObject = result.data.find( + (item: any) => + item && + typeof item === "object" && + "columns" in item && + "results" in item, + ); + + if (dataObject) { + return { + success: true, + data: { + columns: dataObject.columns || [], + results: dataObject.results || [], + }, + }; + } + + const anyDataObject = result.data.find( + (item: any) => + item && + typeof item === "object" && + (Array.isArray(item) || Object.keys(item).length > 0), + ); + + if (anyDataObject) { + if (Array.isArray(anyDataObject)) { + return { + success: true, + data: { + columns: ["result"], + results: anyDataObject, + }, + }; + } + + return { + success: true, + data: { + columns: Object.keys(anyDataObject), + results: [anyDataObject], + }, + }; + } + + return { success: false, error: new Error("No SQL data found in response") }; + } + + return result; + }, + }; + } + + dashboards({ projectId }: { projectId: string }) { + return { + list: async ({ + params, + }: { params?: ListDashboardsData } = {}): Promise< + Result< + Array<{ + id: number; + name: string; + description?: string | null; + }> + > + > => { + const validatedParams = params ? ListDashboardsSchema.parse(params) : undefined; + const searchParams = new URLSearchParams(); + + if (validatedParams?.limit) + searchParams.append("limit", String(validatedParams.limit)); + if (validatedParams?.offset) + searchParams.append("offset", String(validatedParams.offset)); + if (validatedParams?.search) searchParams.append("search", validatedParams.search); + + const url = `${this.baseUrl}/api/projects/${projectId}/dashboards/${searchParams.toString() ? `?${searchParams}` : ""}`; + + const simpleDashboardSchema = z.object({ + id: z.number(), + name: z.string(), + description: z.string().optional().nullable(), + }); + + const responseSchema = z.object({ + results: z.array(simpleDashboardSchema), + }); + + const result = await this.fetchWithSchema(url, responseSchema); + + if (result.success) { + return { success: true, data: result.data.results }; + } + + return result; + }, + + get: async ({ + dashboardId, + }: { dashboardId: number }): Promise< + Result<{ id: number; name: string; description?: string | null }> + > => { + const simpleDashboardSchema = z.object({ + id: z.number(), + name: z.string(), + description: z.string().optional().nullable(), + }); + + return this.fetchWithSchema( + `${this.baseUrl}/api/projects/${projectId}/dashboards/${dashboardId}/`, + simpleDashboardSchema, + ); + }, + + create: async ({ + data, + }: { data: CreateDashboardInput }): Promise> => { + const validatedInput = CreateDashboardInputSchema.parse(data); + + const createResponseSchema = z.object({ + id: z.number(), + name: z.string(), + }); + + return this.fetchWithSchema( + `${this.baseUrl}/api/projects/${projectId}/dashboards/`, + createResponseSchema, + { + method: "POST", + body: JSON.stringify(validatedInput), + }, + ); + }, + + update: async ({ + dashboardId, + data, + }: { dashboardId: number; data: any }): Promise< + Result<{ id: number; name: string }> + > => { + const updateResponseSchema = z.object({ + id: z.number(), + name: z.string(), + }); + + return this.fetchWithSchema( + `${this.baseUrl}/api/projects/${projectId}/dashboards/${dashboardId}/`, + updateResponseSchema, + { + method: "PATCH", + body: JSON.stringify(data), + }, + ); + }, + + delete: async ({ + dashboardId, + }: { dashboardId: number }): Promise> => { + try { + const response = await fetch( + `${this.baseUrl}/api/projects/${projectId}/dashboards/${dashboardId}/`, + { + method: "PATCH", + headers: this.buildHeaders(), + body: JSON.stringify({ deleted: true }), + }, + ); + + if (!response.ok) { + throw new Error(`Failed to delete dashboard: ${response.statusText}`); + } + + return { + success: true, + data: { + success: true, + message: "Dashboard deleted successfully", + }, + }; + } catch (error) { + return { success: false, error: error as Error }; + } + }, + + addInsight: async ({ + data, + }: { data: { insight_id: number; dashboard_id: number } }): Promise> => { + return this.fetchWithSchema( + `${this.baseUrl}/api/projects/${projectId}/insights/${data.insight_id}/`, + z.any(), + { + method: "PATCH", + body: JSON.stringify({ dashboards: [data.dashboard_id] }), + }, + ); + }, + }; + } + + query({ projectId }: { projectId: string }) { + return { + execute: async ({ + queryBody, + }: { queryBody: any }): Promise> => { + const responseSchema = z.object({ + results: z.array(z.any()), + }); + + return this.fetchWithSchema( + `${this.baseUrl}/api/environments/${projectId}/query/`, + responseSchema, + { + method: "POST", + body: JSON.stringify({ query: queryBody }), + }, + ); + }, + }; + } + + users() { + return { + me: async (): Promise> => { + const result = await this.fetchWithSchema( + `${this.baseUrl}/api/users/@me/`, + z.object({ distinct_id: z.string() }), + ); + + if (!result.success) { + return result; + } + + return { + success: true, + data: { distinctId: result.data.distinct_id }, + }; + }, + }; + } +} diff --git a/src/index.ts b/src/index.ts index e40c5ac..010b32c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,33 +2,7 @@ import { McpServer, type ToolCallback } from "@modelcontextprotocol/sdk/server/m import { McpAgent } from "agents/mcp"; import { z } from "zod"; -import { - addInsightToDashboard, - createDashboard, - createFeatureFlag, - createInsight, - deleteDashboard, - deleteFeatureFlag, - deleteInsight, - errorDetails, - getDashboard, - getDashboards, - getFeatureFlagDefinition, - getFeatureFlags, - getInsight, - getInsights, - getLLMTotalCostsForProject, - getOrganizationDetails, - getOrganizations, - getProjects, - getPropertyDefinitions, - getSqlInsight, - getUser, - listErrors, - updateDashboard, - updateFeatureFlag, - updateInsight, -} from "./posthogApi"; +import { ApiClient } from "./api/client"; import { AddInsightToDashboardSchema, @@ -98,13 +72,22 @@ export class MyMCP extends McpAgent { return this._cache; } + get api() { + return new ApiClient({ + apiToken: this.requestProperties.apiToken, + }); + } + async getDistinctId() { let _distinctId = await this.cache.get("distinctId"); if (!_distinctId) { - const user = await getUser(this.requestProperties.apiToken); - await this.cache.set("distinctId", user.distinctId); - _distinctId = user.distinctId; + const userResult = await this.api.users().me(); + if (!userResult.success) { + throw new Error(`Failed to get user: ${userResult.error.message}`); + } + await this.cache.set("distinctId", userResult.data.distinctId); + _distinctId = userResult.data.distinctId; } return _distinctId; @@ -153,12 +136,15 @@ export class MyMCP extends McpAgent { const orgId = await this.cache.get("orgId"); if (!orgId) { - const orgs = await getOrganizations(this.requestProperties.apiToken); + const orgsResult = await this.api.organizations().list(); + if (!orgsResult.success) { + throw new Error(`Failed to get organizations: ${orgsResult.error.message}`); + } // If there is only one org, set it as the active org - if (orgs.length === 1) { - await this.cache.set("orgId", orgs[0].id); - return orgs[0].id; + if (orgsResult.data.length === 1) { + await this.cache.set("orgId", orgsResult.data[0].id); + return orgsResult.data[0].id; } return "@current"; @@ -172,12 +158,15 @@ export class MyMCP extends McpAgent { if (!projectId) { const orgId = await this.getOrgID(); - const projects = await getProjects(orgId, this.requestProperties.apiToken); + const projectsResult = await this.api.organizations().projects({ orgId }).list(); + if (!projectsResult.success) { + throw new Error(`Failed to get projects: ${projectsResult.error.message}`); + } // If there is only one project, set it as the active project - if (projects.length === 1) { - await this.cache.set("projectId", projects[0].id); - return projects[0].id; + if (projectsResult.data.length === 1) { + await this.cache.set("projectId", projectsResult.data[0].id.toString()); + return projectsResult.data[0].id.toString(); } return "@current"; @@ -191,54 +180,55 @@ export class MyMCP extends McpAgent { "feature-flag-get-definition", ` - Use this tool to get the definition of a feature flag. - - You can provide either the flagId or the flagName. + - You can provide either the flagId or the flagKey. - If you provide both, the flagId will be used. `, { flagId: z.string().optional(), - flagName: z.string().optional(), + flagKey: z.string().optional(), }, - async ({ flagId, flagName }) => { - const posthogToken = this.requestProperties.apiToken; - - if (!flagId && !flagName) { + async ({ flagId, flagKey }) => { + if (!flagId && !flagKey) { return { content: [ { type: "text", - text: "Error: Either flagId or flagName must be provided.", + text: "Error: Either flagId or flagKey must be provided.", }, ], }; } - let flagDefinition: any; - const projectId = await this.getProjectId(); if (flagId) { - flagDefinition = await getFeatureFlagDefinition( - projectId, - String(flagId), - posthogToken, - ); + const flagResult = await this.api + .featureFlags({ projectId }) + .get({ flagId: String(flagId) }); + if (!flagResult.success) { + throw new Error(`Failed to get feature flag: ${flagResult.error.message}`); + } return { - content: [{ type: "text", text: JSON.stringify(flagDefinition) }], + content: [{ type: "text", text: JSON.stringify(flagResult.data) }], }; } - if (flagName) { - const allFlags = await getFeatureFlags(projectId, posthogToken); - const foundFlag = allFlags.find((f) => f.key === flagName); - if (foundFlag) { + if (flagKey) { + const flagResult = await this.api + .featureFlags({ projectId }) + .findByKey({ key: flagKey }); + if (!flagResult.success) { + throw new Error(`Failed to find feature flag: ${flagResult.error.message}`); + } + if (flagResult.data) { return { - content: [{ type: "text", text: JSON.stringify(foundFlag) }], + content: [{ type: "text", text: JSON.stringify(flagResult.data) }], }; } return { content: [ { type: "text", - text: `Error: Flag with name "${flagName}" not found.`, + text: `Error: Flag with key "${flagKey}" not found.`, }, ], }; @@ -264,9 +254,12 @@ export class MyMCP extends McpAgent { async () => { const projectId = await this.getProjectId(); - const allFlags = await getFeatureFlags(projectId, this.requestProperties.apiToken); + const flagsResult = await this.api.featureFlags({ projectId }).list(); + if (!flagsResult.success) { + throw new Error(`Failed to get feature flags: ${flagsResult.error.message}`); + } - return { content: [{ type: "text", text: JSON.stringify(allFlags) }] }; + return { content: [{ type: "text", text: JSON.stringify(flagsResult.data) }] }; }, ); @@ -303,10 +296,13 @@ export class MyMCP extends McpAgent { `, {}, async () => { - const organizations = await getOrganizations(this.requestProperties.apiToken); - console.log("organizations", organizations); + const orgsResult = await this.api.organizations().list(); + if (!orgsResult.success) { + throw new Error(`Failed to get organizations: ${orgsResult.error.message}`); + } + console.log("organizations", orgsResult.data); return { - content: [{ type: "text", text: JSON.stringify(organizations) }], + content: [{ type: "text", text: JSON.stringify(orgsResult.data) }], }; }, ); @@ -354,13 +350,15 @@ export class MyMCP extends McpAgent { async () => { const orgId = await this.getOrgID(); - const organizationDetails = await getOrganizationDetails( - orgId, - this.requestProperties.apiToken, - ); - console.log("organization details", organizationDetails); + const orgResult = await this.api.organizations().get({ orgId }); + if (!orgResult.success) { + throw new Error( + `Failed to get organization details: ${orgResult.error.message}`, + ); + } + console.log("organization details", orgResult.data); return { - content: [{ type: "text", text: JSON.stringify(organizationDetails) }], + content: [{ type: "text", text: JSON.stringify(orgResult.data) }], }; }, ); @@ -374,10 +372,13 @@ export class MyMCP extends McpAgent { {}, async () => { const orgId = await this.getOrgID(); - const projects = await getProjects(orgId, this.requestProperties.apiToken); - console.log("projects", projects); + const projectsResult = await this.api.organizations().projects({ orgId }).list(); + if (!projectsResult.success) { + throw new Error(`Failed to get projects: ${projectsResult.error.message}`); + } + console.log("projects", projectsResult.data); return { - content: [{ type: "text", text: JSON.stringify(projects) }], + content: [{ type: "text", text: JSON.stringify(projectsResult.data) }], }; }, ); @@ -391,12 +392,15 @@ export class MyMCP extends McpAgent { async () => { const projectId = await this.getProjectId(); - const propertyDefinitions = await getPropertyDefinitions({ - projectId: projectId, - apiToken: this.requestProperties.apiToken, - }); + const propDefsResult = await this.api.projects().propertyDefinitions({ projectId }); + + if (!propDefsResult.success) { + throw new Error( + `Failed to get property definitions: ${propDefsResult.error.message}`, + ); + } return { - content: [{ type: "text", text: JSON.stringify(propertyDefinitions) }], + content: [{ type: "text", text: JSON.stringify(propDefsResult.data) }], }; }, ); @@ -419,16 +423,18 @@ export class MyMCP extends McpAgent { async ({ name, key, description, filters, active, tags }) => { const projectId = await this.getProjectId(); - const featureFlag = await createFeatureFlag({ - projectId: projectId, - apiToken: this.requestProperties.apiToken, - data: { name, key, description, filters, active, tags }, + const flagResult = await this.api.featureFlags({ projectId }).create({ + data: { name, key, description, filters, active }, }); + if (!flagResult.success) { + throw new Error(`Failed to create feature flag: ${flagResult.error.message}`); + } + // Add URL field for easy navigation const featureFlagWithUrl = { - ...(featureFlag as any), - url: `${getProjectBaseUrl(projectId)}/feature_flags/${(featureFlag as any).id}`, + ...flagResult.data, + url: `${getProjectBaseUrl(projectId)}/feature_flags/${flagResult.data.id}`, }; return { @@ -448,13 +454,31 @@ export class MyMCP extends McpAgent { async ({ data }) => { const projectId = await this.getProjectId(); - const errors = await listErrors({ - projectId: projectId, - data: data, - apiToken: this.requestProperties.apiToken, - }); - console.log("errors results", errors.results); - return { content: [{ type: "text", text: JSON.stringify(errors.results) }] }; + const errorQuery = { + kind: "ErrorTrackingQuery", + orderBy: data.orderBy || "occurrences", + dateRange: { + date_from: + data.dateFrom?.toISOString() || + new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(), + date_to: data.dateTo?.toISOString() || new Date().toISOString(), + }, + volumeResolution: 1, + orderDirection: data.orderDirection || "DESC", + filterTestAccounts: data.filterTestAccounts ?? true, + status: data.status || "active", + }; + + const errorsResult = await this.api + .query({ projectId }) + .execute({ queryBody: errorQuery }); + if (!errorsResult.success) { + throw new Error(`Failed to list errors: ${errorsResult.error.message}`); + } + console.log("errors results", errorsResult.data.results); + return { + content: [{ type: "text", text: JSON.stringify(errorsResult.data.results) }], + }; }, ); @@ -469,13 +493,28 @@ export class MyMCP extends McpAgent { async ({ data }) => { const projectId = await this.getProjectId(); - const errors = await errorDetails({ - projectId: projectId, - data: data, - apiToken: this.requestProperties.apiToken, - }); - console.log("error details results", errors.results); - return { content: [{ type: "text", text: JSON.stringify(errors.results) }] }; + const errorQuery = { + kind: "ErrorTrackingQuery", + dateRange: { + date_from: + data.dateFrom?.toISOString() || + new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(), + date_to: data.dateTo?.toISOString() || new Date().toISOString(), + }, + volumeResolution: 0, + issueId: data.issueId, + }; + + const errorsResult = await this.api + .query({ projectId }) + .execute({ queryBody: errorQuery }); + if (!errorsResult.success) { + throw new Error(`Failed to get error details: ${errorsResult.error.message}`); + } + console.log("error details results", errorsResult.data.results); + return { + content: [{ type: "text", text: JSON.stringify(errorsResult.data.results) }], + }; }, ); @@ -492,17 +531,18 @@ export class MyMCP extends McpAgent { async ({ flagKey, data }) => { const projectId = await this.getProjectId(); - const featureFlag = await updateFeatureFlag({ - projectId: projectId, - apiToken: this.requestProperties.apiToken, + const flagResult = await this.api.featureFlags({ projectId }).update({ key: flagKey, data: data, }); + if (!flagResult.success) { + throw new Error(`Failed to update feature flag: ${flagResult.error.message}`); + } // Add URL field for easy navigation const featureFlagWithUrl = { - ...(featureFlag as any), - url: `${getProjectBaseUrl(projectId)}/feature_flags/${(featureFlag as any).id}`, + ...flagResult.data, + url: `${getProjectBaseUrl(projectId)}/feature_flags/${flagResult.data.id}`, }; return { @@ -522,24 +562,28 @@ export class MyMCP extends McpAgent { async ({ flagKey }) => { const projectId = await this.getProjectId(); - const allFlags = await getFeatureFlags(projectId, this.requestProperties.apiToken); - - const flag = allFlags.find((f) => f.key === flagKey); + const flagResult = await this.api + .featureFlags({ projectId }) + .findByKey({ key: flagKey }); + if (!flagResult.success) { + throw new Error(`Failed to find feature flag: ${flagResult.error.message}`); + } - if (!flag) { + if (!flagResult.data) { return { content: [{ type: "text", text: "Feature flag is already deleted." }], }; } - const featureFlag = await deleteFeatureFlag({ - projectId: projectId, - apiToken: this.requestProperties.apiToken, - flagId: flag.id, + const deleteResult = await this.api.featureFlags({ projectId }).delete({ + flagId: flagResult.data.id, }); + if (!deleteResult.success) { + throw new Error(`Failed to delete feature flag: ${deleteResult.error.message}`); + } return { - content: [{ type: "text", text: JSON.stringify(featureFlag) }], + content: [{ type: "text", text: JSON.stringify(deleteResult.data) }], }; }, ); @@ -577,9 +621,12 @@ export class MyMCP extends McpAgent { const projectId = await this.getProjectId(); - const result = await getSqlInsight({ projectId, apiToken, query }); + const result = await this.api.insights({ projectId }).sqlInsight({ query }); + if (!result.success) { + throw new Error(`Failed to execute SQL insight: ${result.error.message}`); + } - if (result.length === 0) { + if (result.data.results.length === 0) { return { content: [ { @@ -589,7 +636,7 @@ export class MyMCP extends McpAgent { ], }; } - return { content: [{ type: "text", text: JSON.stringify(result) }] }; + return { content: [{ type: "text", text: JSON.stringify(result.data) }] }; }, ); @@ -614,13 +661,36 @@ export class MyMCP extends McpAgent { days: z.number().optional(), }, async ({ projectId, days }) => { - const totalCosts = await getLLMTotalCostsForProject({ - projectId: projectId, - apiToken: this.requestProperties.apiToken, - days: days, - }); + const trendsQuery = { + kind: "TrendsQuery", + dateRange: { + date_from: `-${days || 6}d`, + date_to: null, + }, + filterTestAccounts: true, + series: [ + { + event: "$ai_generation", + name: "$ai_generation", + math: "sum", + math_property: "$ai_total_cost_usd", + kind: "EventsNode", + }, + ], + breakdownFilter: { + breakdown_type: "event", + breakdown: "$ai_model", + }, + }; + + const costsResult = await this.api + .query({ projectId }) + .execute({ queryBody: trendsQuery }); + if (!costsResult.success) { + throw new Error(`Failed to get LLM costs: ${costsResult.error.message}`); + } return { - content: [{ type: "text", text: JSON.stringify(totalCosts.results) }], + content: [{ type: "text", text: JSON.stringify(costsResult.data.results) }], }; }, ); @@ -636,12 +706,20 @@ export class MyMCP extends McpAgent { }, async ({ data }) => { const projectId = await this.getProjectId(); - const insights = await getInsights( - projectId, - this.requestProperties.apiToken, - data, - ); - return { content: [{ type: "text", text: JSON.stringify(insights) }] }; + const insightsResult = await this.api + .insights({ projectId }) + .list({ params: data }); + if (!insightsResult.success) { + throw new Error(`Failed to get insights: ${insightsResult.error.message}`); + } + + // Add URL field to each insight for easy navigation + const insightsWithUrls = insightsResult.data.map((insight) => ({ + ...insight, + url: `${getProjectBaseUrl(projectId)}/insights/${insight.short_id}`, + })); + + return { content: [{ type: "text", text: JSON.stringify(insightsWithUrls) }] }; }, ); @@ -655,12 +733,18 @@ export class MyMCP extends McpAgent { }, async ({ insightId }) => { const projectId = await this.getProjectId(); - const insight = await getInsight( - projectId, - insightId, - this.requestProperties.apiToken, - ); - return { content: [{ type: "text", text: JSON.stringify(insight) }] }; + const insightResult = await this.api.insights({ projectId }).get({ insightId }); + if (!insightResult.success) { + throw new Error(`Failed to get insight: ${insightResult.error.message}`); + } + + // Add URL field for easy navigation + const insightWithUrl = { + ...insightResult.data, + url: `${getProjectBaseUrl(projectId)}/insights/${insightResult.data.short_id}`, + }; + + return { content: [{ type: "text", text: JSON.stringify(insightWithUrl) }] }; }, ); @@ -691,16 +775,15 @@ export class MyMCP extends McpAgent { }, async ({ data }) => { const projectId = await this.getProjectId(); - const insight = await createInsight({ - projectId, - apiToken: this.requestProperties.apiToken, - data, - }); + const insightResult = await this.api.insights({ projectId }).create({ data }); + if (!insightResult.success) { + throw new Error(`Failed to create insight: ${insightResult.error.message}`); + } // Add URL field for easy navigation const insightWithUrl = { - ...insight, - url: `${getProjectBaseUrl(projectId)}/insights/${(insight as any).short_id}`, + ...insightResult.data, + url: `${getProjectBaseUrl(projectId)}/insights/${insightResult.data.short_id}`, }; return { content: [{ type: "text", text: JSON.stringify(insightWithUrl) }] }; @@ -719,17 +802,19 @@ export class MyMCP extends McpAgent { }, async ({ insightId, data }) => { const projectId = await this.getProjectId(); - const insight = await updateInsight({ - projectId, + const insightResult = await this.api.insights({ projectId }).update({ insightId, - apiToken: this.requestProperties.apiToken, data, }); + if (!insightResult.success) { + throw new Error(`Failed to update insight: ${insightResult.error.message}`); + } + // Add URL field for easy navigation const insightWithUrl = { - ...insight, - url: `${getProjectBaseUrl(projectId)}/insights/${(insight as any).short_id}`, + ...insightResult.data, + url: `${getProjectBaseUrl(projectId)}/insights/${insightResult.data.short_id}`, }; return { content: [{ type: "text", text: JSON.stringify(insightWithUrl) }] }; @@ -746,12 +831,13 @@ export class MyMCP extends McpAgent { }, async ({ insightId }) => { const projectId = await this.getProjectId(); - const result = await deleteInsight({ - projectId, - insightId, - apiToken: this.requestProperties.apiToken, - }); - return { content: [{ type: "text", text: JSON.stringify(result) }] }; + const result = await this.api.insights({ projectId }).delete({ insightId }); + + if (!result.success) { + throw new Error(`Failed to delete insight: ${result.error.message}`); + } + + return { content: [{ type: "text", text: JSON.stringify(result.data) }] }; }, ); @@ -767,12 +853,15 @@ export class MyMCP extends McpAgent { }, async ({ data }) => { const projectId = await this.getProjectId(); - const dashboards = await getDashboards( - projectId, - this.requestProperties.apiToken, - data, - ); - return { content: [{ type: "text", text: JSON.stringify(dashboards) }] }; + const dashboardsResult = await this.api + .dashboards({ projectId }) + .list({ params: data }); + + if (!dashboardsResult.success) { + throw new Error(`Failed to get dashboards: ${dashboardsResult.error.message}`); + } + + return { content: [{ type: "text", text: JSON.stringify(dashboardsResult.data) }] }; }, ); @@ -786,12 +875,15 @@ export class MyMCP extends McpAgent { }, async ({ dashboardId }) => { const projectId = await this.getProjectId(); - const dashboard = await getDashboard( - projectId, - dashboardId, - this.requestProperties.apiToken, - ); - return { content: [{ type: "text", text: JSON.stringify(dashboard) }] }; + const dashboardResult = await this.api + .dashboards({ projectId }) + .get({ dashboardId }); + + if (!dashboardResult.success) { + throw new Error(`Failed to get dashboard: ${dashboardResult.error.message}`); + } + + return { content: [{ type: "text", text: JSON.stringify(dashboardResult.data) }] }; }, ); @@ -806,16 +898,16 @@ export class MyMCP extends McpAgent { }, async ({ data }) => { const projectId = await this.getProjectId(); - const dashboard = await createDashboard({ - projectId, - apiToken: this.requestProperties.apiToken, - data, - }); + const dashboardResult = await this.api.dashboards({ projectId }).create({ data }); + + if (!dashboardResult.success) { + throw new Error(`Failed to create dashboard: ${dashboardResult.error.message}`); + } // Add URL field for easy navigation const dashboardWithUrl = { - ...(dashboard as any), - url: `${getProjectBaseUrl(projectId)}/dashboard/${dashboard.id}`, + ...dashboardResult.data, + url: `${getProjectBaseUrl(projectId)}/dashboard/${dashboardResult.data.id}`, }; return { content: [{ type: "text", text: JSON.stringify(dashboardWithUrl) }] }; @@ -834,17 +926,18 @@ export class MyMCP extends McpAgent { }, async ({ dashboardId, data }) => { const projectId = await this.getProjectId(); - const dashboard = await updateDashboard({ - projectId, - dashboardId, - apiToken: this.requestProperties.apiToken, - data, - }); + const dashboardResult = await this.api + .dashboards({ projectId }) + .update({ dashboardId, data }); + + if (!dashboardResult.success) { + throw new Error(`Failed to update dashboard: ${dashboardResult.error.message}`); + } // Add URL field for easy navigation const dashboardWithUrl = { - ...(dashboard as any), - url: `${getProjectBaseUrl(projectId)}/dashboard/${dashboard.id}`, + ...dashboardResult.data, + url: `${getProjectBaseUrl(projectId)}/dashboard/${dashboardResult.data.id}`, }; return { content: [{ type: "text", text: JSON.stringify(dashboardWithUrl) }] }; @@ -861,12 +954,13 @@ export class MyMCP extends McpAgent { }, async ({ dashboardId }) => { const projectId = await this.getProjectId(); - const result = await deleteDashboard({ - projectId, - dashboardId, - apiToken: this.requestProperties.apiToken, - }); - return { content: [{ type: "text", text: JSON.stringify(result) }] }; + const result = await this.api.dashboards({ projectId }).delete({ dashboardId }); + + if (!result.success) { + throw new Error(`Failed to delete dashboard: ${result.error.message}`); + } + + return { content: [{ type: "text", text: JSON.stringify(result.data) }] }; }, ); @@ -882,17 +976,26 @@ export class MyMCP extends McpAgent { }, async ({ data }) => { const projectId = await this.getProjectId(); - const result = await addInsightToDashboard({ - projectId, - apiToken: this.requestProperties.apiToken, - data, - }); + + // Get insight to retrieve short_id for URL + const insightResult = await this.api + .insights({ projectId }) + .get({ insightId: data.insight_id }); + if (!insightResult.success) { + throw new Error(`Failed to get insight: ${insightResult.error.message}`); + } + + const result = await this.api.dashboards({ projectId }).addInsight({ data }); + + if (!result.success) { + throw new Error(`Failed to add insight to dashboard: ${result.error.message}`); + } // Add URLs for easy navigation const resultWithUrls = { - ...(result as any), + ...result.data, dashboard_url: `${getProjectBaseUrl(projectId)}/dashboard/${data.dashboard_id}`, - insight_url: `${getProjectBaseUrl(projectId)}/insights/${data.insight_id}`, + insight_url: `${getProjectBaseUrl(projectId)}/insights/${insightResult.data.short_id}`, }; return { content: [{ type: "text", text: JSON.stringify(resultWithUrls) }] }; diff --git a/src/lib/utils/api.ts b/src/lib/utils/api.ts index 49d9f8f..87b45d1 100644 --- a/src/lib/utils/api.ts +++ b/src/lib/utils/api.ts @@ -1,4 +1,5 @@ import { ApiResponseSchema } from "../../schema/api"; +import { BASE_URL } from "../constants"; import type { z } from "zod"; export const withPagination = async ( @@ -34,8 +35,8 @@ export const withPagination = async ( export const getProjectBaseUrl = (projectId: string) => { if (projectId === "@current") { - return "https://us.posthog.com"; + return BASE_URL; } - return `https://us.posthog.com/project/${projectId}`; + return `${BASE_URL}/project/${projectId}`; }; diff --git a/src/posthogApi.ts b/src/posthogApi.ts deleted file mode 100644 index 04f8d95..0000000 --- a/src/posthogApi.ts +++ /dev/null @@ -1,796 +0,0 @@ -import { withPagination } from "./lib/utils/api"; -import { ApiPropertyDefinitionSchema } from "./schema/api"; - -import { BASE_URL } from "./lib/constants"; -import { ErrorCode } from "./lib/errors"; -import { - type AddInsightToDashboardInput, - type CreateDashboardInput, - DashboardSchema, - type ListDashboardsData, - type PostHogDashboard, - type UpdateDashboardInput, -} from "./schema/dashboards"; -import { - type ErrorDetailsData, - type ListErrorsData, - OrderByErrors, - OrderDirectionErrors, - StatusErrors, -} from "./schema/errors"; -import { - type CreateFeatureFlagInput, - FeatureFlagSchema, - type PostHogFeatureFlag, - type UpdateFeatureFlagInput, -} from "./schema/flags"; -import { - type CreateInsightInput, - type ListInsightsData, - type PostHogInsight, - SQLInsightResponseSchema, - type UpdateInsightInput, -} from "./schema/insights"; -import type { Organization } from "./schema/orgs"; -import type { Project } from "./schema/projects"; -import { PropertyDefinitionSchema } from "./schema/properties"; - -export async function getFeatureFlagDefinition( - projectId: string, - flagId: string, - apiToken: string, -) { - const response = await fetch(`${BASE_URL}/api/projects/${projectId}/feature_flags/${flagId}/`, { - headers: { - Authorization: `Bearer ${apiToken}`, - }, - }); - if (!response.ok) { - throw new Error(`Failed to fetch feature flag: ${response.statusText}`); - } - return response.json(); -} -export async function getFeatureFlags( - projectId: string, - apiToken: string, -): Promise { - const response = await withPagination( - `${BASE_URL}/api/projects/${projectId}/feature_flags/`, - apiToken, - FeatureFlagSchema.pick({ - id: true, - key: true, - name: true, - active: true, - }), - ); - - return response; -} - -export async function getOrganizations(apiToken: string): Promise { - const response = await fetch(`${BASE_URL}/api/organizations/`, { - headers: { - Authorization: `Bearer ${apiToken}`, - }, - }); - if (!response.ok) { - if (response.status === 401) { - throw new Error(ErrorCode.INVALID_API_KEY); - } - - throw new Error(`Failed to fetch organizations: ${response.statusText}`); - } - return response.json(); -} - -export async function getOrganizationDetails(orgId: string, apiToken: string) { - const response = await fetch(`${BASE_URL}/api/organizations/${orgId}/`, { - headers: { - Authorization: `Bearer ${apiToken}`, - }, - }); - if (!response.ok) { - throw new Error(`Failed to fetch organization details: ${response.statusText}`); - } - return response.json(); -} - -export async function getProjects(orgId: string, apiToken: string): Promise { - const response = await fetch(`${BASE_URL}/api/organizations/${orgId}/projects/`, { - headers: { - Authorization: `Bearer ${apiToken}`, - }, - }); - if (!response.ok) { - if (response.status === 401) { - throw new Error(ErrorCode.INVALID_API_KEY); - } - - throw new Error(`Failed to fetch projects: ${response.statusText}`); - } - return response.json(); -} - -export async function getPropertyDefinitions({ - projectId, - apiToken, -}: { projectId: string; apiToken: string }) { - const propertyDefinitions = await withPagination( - `${BASE_URL}/api/projects/${projectId}/property_definitions/`, - apiToken, - ApiPropertyDefinitionSchema, - ); - - const propertyDefinitionsWithoutHidden = propertyDefinitions.filter((def) => !def.hidden); - - return propertyDefinitionsWithoutHidden.map((def) => PropertyDefinitionSchema.parse(def)); -} - -export async function createFeatureFlag({ - projectId, - apiToken, - data, -}: { projectId: string; apiToken: string; data: CreateFeatureFlagInput }) { - const body = { - key: data.key, - name: data.name, - description: data.description, - active: data.active, - filters: data.filters, - tags: data.tags, - }; - - const response = await fetch(`${BASE_URL}/api/projects/${projectId}/feature_flags/`, { - method: "POST", - headers: { - Authorization: `Bearer ${apiToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - const responseData = (await response.json()) as { type?: string; code?: string }; - - if (responseData.type === "validation_error" && responseData.code === "unique") { - throw new Error("Feature flag already exists with this key"); - } - - throw new Error(`Failed to create feature flag: ${response.statusText}`); - } - - const responseData = await response.json(); - - return responseData; -} - -function hasResultsProperty(data: unknown): data is { results: PostHogInsight[] } { - return typeof data === "object" && data !== null && "results" in data; -} - -function isErrorResponse(data: unknown): data is { results: unknown[] } { - return typeof data === "object" && data !== null && "results" in data; -} - -function isLLMCostsResponse(data: unknown): data is { results: unknown[] } { - if (typeof data !== "object" || data === null) { - return false; - } - - if (!("results" in data)) { - return false; - } - - const obj = data as Record; - return Array.isArray(obj.results); -} - -export async function listErrors({ - projectId, - data, - apiToken, -}: { projectId: string; data: ListErrorsData; apiToken: string | undefined }): Promise<{ - results: unknown[]; -}> { - const date = new Date(); - date.setDate(date.getDate() - 7); - const dateFromToUse = data.dateFrom?.toISOString() ?? date.toISOString(); - const dateToToUse = data.dateTo?.toISOString() ?? new Date().toISOString(); - const orderByToUse = data.orderBy ?? OrderByErrors.Occurrences; - const orderDirectionToUse = data.orderDirection ?? OrderDirectionErrors.Descending; - const filterTestAccountsToUse = data.filterTestAccounts ?? true; - // const limitToUse = data.limit ?? 50; - const statusToUse = data.status ?? StatusErrors.Active; - const body = { - query: { - kind: "ErrorTrackingQuery", - orderBy: orderByToUse, - dateRange: { - date_from: dateFromToUse, - date_to: dateToToUse, - }, - volumeResolution: 1, // we dont care about sparkline volumes yet mandatory - orderDirection: orderDirectionToUse, - filterTestAccounts: filterTestAccountsToUse, - // limit: limitToUse, - status: statusToUse, - }, - }; - - const response = await fetch(`${BASE_URL}/api/environments/${projectId}/query/`, { - method: "POST", - headers: { - Authorization: `Bearer ${apiToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - throw new Error(`Failed to fetch errors: ${response.statusText}`); - } - - const responseData: unknown = await response.json(); - - if (!isErrorResponse(responseData)) { - throw new Error("Invalid response format: expected object with results property"); - } - - return responseData; -} - -export async function errorDetails({ - projectId, - data, - apiToken, -}: { projectId: string; data: ErrorDetailsData; apiToken: string | undefined }): Promise<{ - results: unknown[]; -}> { - const date = new Date(); - date.setDate(date.getDate() - 7); - const dateFromToUse = data.dateFrom?.toISOString() ?? date.toISOString(); - const dateToToUse = data.dateTo?.toISOString() ?? new Date().toISOString(); - const body = { - query: { - kind: "ErrorTrackingQuery", - dateRange: { - date_from: dateFromToUse, - date_to: dateToToUse, - }, - volumeResolution: 0, // we dont care about sparkline volumes yet mandatory - issueId: data.issueId, - }, - }; - - const response = await fetch(`https://us.posthog.com/api/environments/${projectId}/query/`, { - method: "POST", - headers: { - Authorization: `Bearer ${apiToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - throw new Error(`Failed to fetch error details: ${response.statusText}`); - } - - const responseData: unknown = await response.json(); - - if (!isErrorResponse(responseData)) { - throw new Error("Invalid response format: expected object with results property"); - } - - return responseData; -} - -export async function updateFeatureFlag({ - projectId, - apiToken, - key, - data, -}: { projectId: string; apiToken: string; key: string; data: UpdateFeatureFlagInput }) { - const allFlags = await getFeatureFlags(projectId, apiToken); - const flag = allFlags.find((f) => f.key === key); - - if (!flag) { - throw new Error(`Feature flag not found: ${key}`); - } - - const body = { - key: key, - name: data.name, - description: data.description, - active: data.active, - filters: data.filters, - tags: data.tags, - }; - - const response = await fetch( - `${BASE_URL}/api/projects/${projectId}/feature_flags/${flag.id}/`, - { - method: "PATCH", - headers: { - Authorization: `Bearer ${apiToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - }, - ); - - if (!response.ok) { - throw new Error(`Failed to update feature flag: ${response.statusText}`); - } - - const responseData = await response.json(); - - return responseData; -} - -export async function deleteFeatureFlag({ - projectId, - apiToken, - flagId, -}: { projectId: string; apiToken: string; flagId: number }) { - const response = await fetch(`${BASE_URL}/api/projects/${projectId}/feature_flags/${flagId}/`, { - method: "PATCH", - body: JSON.stringify({ deleted: true }), - headers: { - Authorization: `Bearer ${apiToken}`, - "Content-Type": "application/json", - }, - }); - - if (!response.ok) { - throw new Error(`Failed to delete feature flag: ${response.statusText}`); - } - - return { - success: true, - message: "Feature flag deleted successfully", - }; -} - -export async function getSqlInsight({ - projectId, - apiToken, - query, -}: { - projectId: string; - apiToken: string; - query: string; -}) { - const requestBody = { - query: query, - insight_type: "sql", - }; - - const response = await fetch( - `${BASE_URL}/api/environments/${projectId}/max_tools/create_and_query_insight/`, - { - method: "POST", - headers: { - Authorization: `Bearer ${apiToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(requestBody), - }, - ); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error( - `Failed to create and query SQL insight: ${response.statusText}. Details: ${errorText}`, - ); - } - - const responseData = await response.json(); - - const parsedData = SQLInsightResponseSchema.safeParse(responseData); - - if (!parsedData.success) { - throw new Error(`Failed to parse SQL insight response: ${parsedData.error}`); - } - - return parsedData.data; -} - -// Type guards for API responses -function isPostHogInsightArray(data: unknown): data is PostHogInsight[] { - return Array.isArray(data); -} - -export async function getLLMTotalCostsForProject({ - projectId, - apiToken, - days, -}: { projectId: string; apiToken: string; days?: number }): Promise<{ results: unknown[] }> { - const body = { - query: { - kind: "TrendsQuery", - dateRange: { - date_from: `-${days ?? 6}d`, - date_to: null, - }, - - filterTestAccounts: true, - series: [ - { - event: "$ai_generation", - name: "$ai_generation", - math: "sum", - math_property: "$ai_total_cost_usd", - kind: "EventsNode", - }, - ], - breakdownFilter: { - breakdown_type: "event", - breakdown: "$ai_model", - }, - }, - }; - const response = await fetch(`https://us.posthog.com/api/environments/${projectId}/query/`, { - method: "POST", - headers: { - Authorization: `Bearer ${apiToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Failed to fetch llm total costs for project: ${response.statusText}`); - } - - const responseData: unknown = await response.json(); - - if (!isLLMCostsResponse(responseData)) { - throw new Error("Invalid response format: expected object with results array"); - } - - return responseData; -} - -export async function getInsights( - projectId: string, - apiToken: string, - params?: ListInsightsData, -): Promise { - const searchParams = new URLSearchParams(); - if (params?.limit) searchParams.append("limit", String(params.limit)); - if (params?.offset) searchParams.append("offset", String(params.offset)); - if (params?.saved !== undefined) searchParams.append("saved", String(params.saved)); - if (params?.favorited !== undefined) searchParams.append("favorited", String(params.favorited)); - if (params?.search) searchParams.append("search", params.search); - - const url = `${BASE_URL}/api/projects/${projectId}/insights/${searchParams.toString() ? `?${searchParams}` : ""}`; - - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${apiToken}`, - }, - }); - - if (!response.ok) { - throw new Error(`Failed to fetch insights: ${response.statusText}`); - } - - const data: unknown = await response.json(); - - if (isPostHogInsightArray(data)) { - return data; - } - - if (hasResultsProperty(data)) { - return data.results; - } - - throw new Error( - "Invalid response format: expected PostHogInsight array or object with results property", - ); -} - -export async function getInsight( - projectId: string, - insightId: number, - apiToken: string, -): Promise { - const response = await fetch(`${BASE_URL}/api/projects/${projectId}/insights/${insightId}/`, { - headers: { - Authorization: `Bearer ${apiToken}`, - }, - }); - - if (!response.ok) { - throw new Error(`Failed to fetch insight: ${response.statusText}`); - } - - return response.json(); -} - -export async function createInsight({ - projectId, - apiToken, - data, -}: { projectId: string; apiToken: string; data: CreateInsightInput }): Promise { - const response = await fetch(`${BASE_URL}/api/projects/${projectId}/insights/`, { - method: "POST", - headers: { - Authorization: `Bearer ${apiToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(data), - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Failed to create insight: ${response.statusText}. ${errorText}`); - } - - return response.json(); -} - -export async function updateInsight({ - projectId, - insightId, - apiToken, - data, -}: { - projectId: string; - insightId: number; - apiToken: string; - data: UpdateInsightInput; -}): Promise { - const response = await fetch(`${BASE_URL}/api/projects/${projectId}/insights/${insightId}/`, { - method: "PATCH", - headers: { - Authorization: `Bearer ${apiToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(data), - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Failed to update insight: ${response.statusText}. ${errorText}`); - } - - return response.json(); -} - -export async function deleteInsight({ - projectId, - insightId, - apiToken, -}: { projectId: string; insightId: number; apiToken: string }) { - const response = await fetch(`${BASE_URL}/api/projects/${projectId}/insights/${insightId}/`, { - method: "PATCH", - headers: { - Authorization: `Bearer ${apiToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ deleted: true }), - }); - - if (!response.ok) { - throw new Error(`Failed to delete insight: ${response.statusText}`); - } - - return { - success: true, - message: "Insight deleted successfully", - }; -} - -// Dashboard API functions - -function isDashboardArray(data: unknown): data is PostHogDashboard[] { - return Array.isArray(data); -} - -function hasDashboardResults(data: unknown): data is { results: PostHogDashboard[] } { - return typeof data === "object" && data !== null && "results" in data; -} - -export async function getDashboards( - projectId: string, - apiToken: string, - params?: ListDashboardsData, -): Promise { - const searchParams = new URLSearchParams(); - if (params?.limit) searchParams.append("limit", String(params.limit)); - if (params?.offset) searchParams.append("offset", String(params.offset)); - if (params?.search) searchParams.append("search", params.search); - if (params?.pinned !== undefined) searchParams.append("pinned", String(params.pinned)); - - const url = `${BASE_URL}/api/projects/${projectId}/dashboards/${searchParams.toString() ? `?${searchParams}` : ""}`; - - const response = await fetch(url, { - headers: { - Authorization: `Bearer ${apiToken}`, - }, - }); - - if (!response.ok) { - throw new Error(`Failed to fetch dashboards: ${response.statusText}`); - } - - const data: unknown = await response.json(); - - if (isDashboardArray(data)) { - return data; - } - - if (hasDashboardResults(data)) { - try { - return data.results.map((dashboard) => DashboardSchema.parse(dashboard)); - } catch (error) { - throw new Error(`Error parsing dashboard: ${error}`); - } - } - - throw new Error( - "Invalid response format: expected PostHogDashboard array or object with results property", - ); -} - -export async function getDashboard( - projectId: string, - dashboardId: number, - apiToken: string, -): Promise { - const response = await fetch( - `${BASE_URL}/api/projects/${projectId}/dashboards/${dashboardId}/`, - { - headers: { - Authorization: `Bearer ${apiToken}`, - }, - }, - ); - - if (!response.ok) { - throw new Error(`Failed to fetch dashboard: ${response.statusText}`); - } - - return response.json(); -} - -export async function createDashboard({ - projectId, - apiToken, - data, -}: { projectId: string; apiToken: string; data: CreateDashboardInput }): Promise { - const response = await fetch(`${BASE_URL}/api/projects/${projectId}/dashboards/`, { - method: "POST", - headers: { - Authorization: `Bearer ${apiToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(data), - }); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Failed to create dashboard: ${response.statusText}. ${errorText}`); - } - - return response.json(); -} - -export async function updateDashboard({ - projectId, - dashboardId, - apiToken, - data, -}: { - projectId: string; - dashboardId: number; - apiToken: string; - data: UpdateDashboardInput; -}): Promise { - const response = await fetch( - `${BASE_URL}/api/projects/${projectId}/dashboards/${dashboardId}/`, - { - method: "PATCH", - headers: { - Authorization: `Bearer ${apiToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(data), - }, - ); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Failed to update dashboard: ${response.statusText}. ${errorText}`); - } - - return response.json(); -} - -export async function deleteDashboard({ - projectId, - dashboardId, - apiToken, -}: { projectId: string; dashboardId: number; apiToken: string }) { - const response = await fetch( - `${BASE_URL}/api/projects/${projectId}/dashboards/${dashboardId}/`, - { - method: "PATCH", - headers: { - Authorization: `Bearer ${apiToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ deleted: true }), - }, - ); - - if (!response.ok) { - throw new Error(`Failed to delete dashboard: ${response.statusText}`); - } - - return { - success: true, - message: "Dashboard deleted successfully", - }; -} - -export async function addInsightToDashboard({ - projectId, - apiToken, - data, -}: { projectId: string; apiToken: string; data: AddInsightToDashboardInput }) { - // Based on PostHog API documentation and community feedback: - // - The dashboard_tiles endpoint doesn't exist for creation - // - The Dashboard API can only UPDATE tiles, not CREATE them - // - We must use the deprecated 'dashboards' array field as it's the only way that works - // See: https://posthog.com/docs/api/insights (Community Questions section) - const updateData: any = { - dashboards: [data.dashboard_id], // Use deprecated array field - only working approach - }; - - const response = await fetch( - `${BASE_URL}/api/projects/${projectId}/insights/${data.insight_id}/`, - { - method: "PATCH", - headers: { - Authorization: `Bearer ${apiToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify(updateData), - }, - ); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`Failed to add insight to dashboard: ${response.statusText}. ${errorText}`); - } - - return response.json(); -} - -export async function getUser(apiToken: string) { - const response = await fetch(`${BASE_URL}/api/users/@me/`, { - headers: { - Authorization: `Bearer ${apiToken}`, - }, - }); - - if (!response.ok) { - throw new Error(`Failed to fetch user: ${response.statusText}`); - } - - const data = (await response.json()) as { distinct_id: string }; - - const distinctId = data.distinct_id; - - if (!distinctId) { - throw new Error("User does not have a distinct_id"); - } - - return { - distinctId, - }; -} diff --git a/src/schema/insights.ts b/src/schema/insights.ts index cdcf35b..ed4ad77 100644 --- a/src/schema/insights.ts +++ b/src/schema/insights.ts @@ -4,9 +4,9 @@ import { InsightQuerySchema } from "./query"; export const InsightSchema = z.object({ id: z.number(), name: z.string(), - description: z.string().optional(), + description: z.string().optional().nullable(), filters: z.record(z.any()), - query: z.record(z.any()).optional(), + query: z.record(z.any()).optional().nullable(), result: z.any().optional(), created_at: z.string(), updated_at: z.string(), @@ -18,16 +18,17 @@ export const InsightSchema = z.object({ first_name: z.string(), email: z.string(), }) - .optional(), + .optional() + .nullable(), saved: z.boolean(), - favorited: z.boolean().optional(), + favorited: z.boolean().optional().nullable(), deleted: z.boolean(), - dashboard: z.number().optional(), - layouts: z.record(z.any()).optional(), - color: z.string().optional(), - last_refresh: z.string().optional(), - refreshing: z.boolean().optional(), - tags: z.array(z.string()).optional(), + dashboard: z.number().optional().nullable(), + layouts: z.record(z.any()).optional().nullable(), + color: z.string().optional().nullable(), + last_refresh: z.string().optional().nullable(), + refreshing: z.boolean().optional().nullable(), + tags: z.array(z.string()).optional().nullable(), }); export const CreateInsightInputSchema = z.object({ diff --git a/src/schema/projects.ts b/src/schema/projects.ts index 1d8d043..334e891 100644 --- a/src/schema/projects.ts +++ b/src/schema/projects.ts @@ -1,7 +1,7 @@ import { z } from "zod"; export const ProjectSchema = z.object({ - id: z.string(), + id: z.number(), name: z.string(), }); diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..70b01d5 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,21 @@ +# API Integration Tests + +These tests should verify that the API works as intended. + +## Setup + +1. **Configure environment:** + ```bash + cp .env.test.example .env.test + ``` + Edit `.env.test` and set an api token and base url for your local PostHog instance + +## Running Tests + +```bash +# Run all tests +npm run test + +# Run tests in watch mode +npm run test:watch +``` \ No newline at end of file diff --git a/tests/api/client.test.ts b/tests/api/client.test.ts new file mode 100644 index 0000000..3324efa --- /dev/null +++ b/tests/api/client.test.ts @@ -0,0 +1,579 @@ +import { describe, it, expect, beforeAll, afterAll, afterEach } from "vitest"; +import { type api, ApiClient } from "../../src/api/client"; + +const API_BASE_URL = process.env.TEST_API_BASE_URL || "http://localhost:8010"; +const API_TOKEN = process.env.TEST_API_TOKEN; + +describe("API Client Integration Tests", () => { + let client: ReturnType; + let testOrgId: string; + let testProjectId: string; + + // Track created resources for cleanup + const createdResources = { + featureFlags: [] as number[], + insights: [] as number[], + dashboards: [] as number[], + }; + + beforeAll(async () => { + if (!API_TOKEN) { + throw new Error("TEST_API_TOKEN environment variable is required"); + } + + client = new ApiClient({ + apiToken: API_TOKEN, + baseUrl: API_BASE_URL, + }); + + // Create test organization + const testOrgName = `test-org-${Date.now()}`; + console.log(`Creating test organization: ${testOrgName}`); + + try { + const createOrgResult = await fetch(`${API_BASE_URL}/api/organizations/`, { + method: "POST", + headers: { + Authorization: `Bearer ${API_TOKEN}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: testOrgName, + }), + }); + + if (!createOrgResult.ok) { + throw new Error( + `Failed to create test organization: ${createOrgResult.statusText}`, + ); + } + + const orgData = await createOrgResult.json(); + testOrgId = orgData.id; + console.log(`Created test organization with ID: ${testOrgId}`); + } catch (error) { + console.error("Failed to create test organization:", error); + throw error; + } + + // Create test project + const testProjectName = `test-project-${Date.now()}`; + console.log(`Creating test project: ${testProjectName} in org: ${testOrgId}`); + + try { + const createProjectResult = await fetch( + `${API_BASE_URL}/api/organizations/${testOrgId}/projects/`, + { + method: "POST", + headers: { + Authorization: `Bearer ${API_TOKEN}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: testProjectName, + }), + }, + ); + + if (!createProjectResult.ok) { + throw new Error(`Failed to create test project: ${createProjectResult.statusText}`); + } + + const projectData = await createProjectResult.json(); + testProjectId = projectData.id; + console.log(`Created test project with ID: ${testProjectId}`); + } catch (error) { + console.error("Failed to create test project:", error); + throw error; + } + }); + + afterAll(async () => { + // Clean up test project + if (testProjectId) { + console.log(`Cleaning up test project: ${testProjectId}`); + try { + const deleteResult = await fetch(`${API_BASE_URL}/api/projects/${testProjectId}/`, { + method: "DELETE", + headers: { + Authorization: `Bearer ${API_TOKEN}`, + }, + }); + + if (deleteResult.ok) { + console.log(`Successfully deleted test project: ${testProjectId}`); + } else { + console.warn(`Failed to delete test project: ${deleteResult.statusText}`); + } + } catch (error) { + console.warn("Error deleting test project:", error); + } + } + + // Clean up test organization + if (testOrgId) { + console.log(`Cleaning up test organization: ${testOrgId}`); + try { + const deleteResult = await fetch( + `${API_BASE_URL}/api/organizations/${testOrgId}/`, + { + method: "DELETE", + headers: { + Authorization: `Bearer ${API_TOKEN}`, + }, + }, + ); + + if (deleteResult.ok) { + console.log(`Successfully deleted test organization: ${testOrgId}`); + } else { + console.warn(`Failed to delete test organization: ${deleteResult.statusText}`); + } + } catch (error) { + console.warn("Error deleting test organization:", error); + } + } + }); + + afterEach(async () => { + // Clean up created feature flags + for (const flagId of createdResources.featureFlags) { + try { + await client.featureFlags({ projectId: testProjectId }).delete({ flagId }); + } catch (error) { + console.warn(`Failed to cleanup feature flag ${flagId}:`, error); + } + } + createdResources.featureFlags = []; + + // Clean up created insights + for (const insightId of createdResources.insights) { + try { + await client.insights({ projectId: testProjectId }).delete({ insightId }); + } catch (error) { + console.warn(`Failed to cleanup insight ${insightId}:`, error); + } + } + createdResources.insights = []; + + // Clean up created dashboards + for (const dashboardId of createdResources.dashboards) { + try { + await client.dashboards({ projectId: testProjectId }).delete({ dashboardId }); + } catch (error) { + console.warn(`Failed to cleanup dashboard ${dashboardId}:`, error); + } + } + createdResources.dashboards = []; + }); + + describe("Organizations API", () => { + it("should list organizations", async () => { + const result = await client.organizations().list(); + + if (!result.success) { + console.error("Failed to list organizations:", (result as any).error); + } + + expect(result.success).toBe(true); + + if (result.success) { + expect(Array.isArray(result.data)).toBe(true); + if (result.data.length > 0) { + const org = result.data[0]; + expect(org).toHaveProperty("id"); + expect(org).toHaveProperty("name"); + expect(typeof org.id).toBe("string"); + expect(typeof org.name).toBe("string"); + } + } + }); + + it("should get organization details", async () => { + const result = await client.organizations().get({ orgId: testOrgId }); + + expect(result.success).toBe(true); + + if (result.success) { + expect(result.data).toHaveProperty("id"); + expect(result.data).toHaveProperty("name"); + expect(result.data.id).toBe(testOrgId); + } + }); + + it("should list projects for organization", async () => { + const result = await client.organizations().projects({ orgId: testOrgId }).list(); + + if (!result.success) { + console.error("Failed to list projects:", (result as any).error); + } + + expect(result.success).toBe(true); + + if (result.success) { + expect(Array.isArray(result.data)).toBe(true); + if (result.data.length > 0) { + const project = result.data[0]; + expect(project).toHaveProperty("id"); + expect(project).toHaveProperty("name"); + expect(typeof project.id).toBe("number"); + expect(typeof project.name).toBe("string"); + } + } + }); + }); + + describe("Projects API", () => { + it("should get project details", async () => { + const result = await client.projects().get({ projectId: testProjectId }); + + if (!result.success) { + console.error("Failed to get project details:", (result as any).error); + } + + expect(result.success).toBe(true); + + if (result.success) { + expect(result.data).toHaveProperty("id"); + expect(result.data).toHaveProperty("name"); + expect(result.data.id).toBe(testProjectId); + } + }); + + it("should get property definitions", async () => { + const result = await client + .projects() + .propertyDefinitions({ projectId: testProjectId }); + + expect(result.success).toBe(true); + + if (result.success) { + expect(Array.isArray(result.data)).toBe(true); + if (result.data.length > 0) { + const propDef = result.data[0]; + expect(propDef).toHaveProperty("id"); + expect(propDef).toHaveProperty("name"); + } + } + }); + }); + + describe("Feature Flags API", () => { + it("should list feature flags", async () => { + const result = await client.featureFlags({ projectId: testProjectId }).list(); + + expect(result.success).toBe(true); + + if (result.success) { + expect(Array.isArray(result.data)).toBe(true); + for (const flag of result.data) { + expect(flag).toHaveProperty("id"); + expect(flag).toHaveProperty("key"); + expect(flag).toHaveProperty("name"); + expect(flag).toHaveProperty("active"); + expect(typeof flag.id).toBe("number"); + expect(typeof flag.key).toBe("string"); + expect(typeof flag.name).toBe("string"); + expect(typeof flag.active).toBe("boolean"); + } + } + }); + + it("should create, get, update, and delete a feature flag", async () => { + const testKey = `test-flag-${Date.now()}`; + + // Create + const createResult = await client.featureFlags({ projectId: testProjectId }).create({ + data: { + key: testKey, + name: "Test Flag", + description: "Test feature flag", + active: true, + filters: { groups: [] }, + }, + }); + + expect(createResult.success).toBe(true); + + if (createResult.success) { + const flagId = createResult.data.id; + createdResources.featureFlags.push(flagId); + + // Get by ID + const getResult = await client + .featureFlags({ projectId: testProjectId }) + .get({ flagId }); + expect(getResult.success).toBe(true); + + if (getResult.success) { + expect(getResult.data.key).toBe(testKey); + expect(getResult.data.name).toBe("Test Flag"); + } + + // Find by key + const findResult = await client + .featureFlags({ projectId: testProjectId }) + .findByKey({ key: testKey }); + expect(findResult.success).toBe(true); + + if (findResult.success && findResult.data) { + expect(findResult.data.id).toBe(flagId); + expect(findResult.data.key).toBe(testKey); + } + + // Update + const updateResult = await client + .featureFlags({ projectId: testProjectId }) + .update({ + key: testKey, + data: { + name: "Updated Test Flag", + active: false, + }, + }); + expect(updateResult.success).toBe(true); + + // Verify update + const updatedGetResult = await client + .featureFlags({ projectId: testProjectId }) + .get({ flagId }); + if (updatedGetResult.success) { + expect(updatedGetResult.data.name).toBe("Updated Test Flag"); + expect(updatedGetResult.data.active).toBe(false); + } + + // Delete will be handled by afterEach cleanup + } + }); + }); + + describe("Insights API", () => { + it("should list insights", async () => { + const result = await client.insights({ projectId: testProjectId }).list(); + + expect(result.success).toBe(true); + + if (result.success) { + expect(Array.isArray(result.data)).toBe(true); + for (const insight of result.data) { + expect(insight).toHaveProperty("id"); + expect(insight).toHaveProperty("name"); + expect(typeof insight.id).toBe("number"); + expect(typeof insight.name).toBe("string"); + } + } + }); + + it.skip("should execute SQL insight query", async () => { + const result = await client + .insights({ projectId: testProjectId }) + .sqlInsight({ query: "SELECT 1 as test" }); + + if (!result.success) { + console.error("Failed to execute SQL insight:", (result as any).error); + } + + expect(result.success).toBe(true); + + if (result.success) { + expect(result.data).toHaveProperty("columns"); + expect(result.data).toHaveProperty("results"); + expect(Array.isArray(result.data.columns)).toBe(true); + expect(Array.isArray(result.data.results)).toBe(true); + } + }); + + it("should create, get, update, and delete an insight", async () => { + const createResult = await client.insights({ projectId: testProjectId }).create({ + data: { + name: "Test Insight", + query: { + kind: "DataVisualizationNode", + source: { + kind: "HogQLQuery", + query: "SELECT 1 as test", + filters: { + dateRange: { + date_from: "-7d", + date_to: "1d", + }, + }, + }, + }, + }, + }); + + if (!createResult.success) { + console.error("Failed to create insight:", (createResult as any).error); + } + + expect(createResult.success).toBe(true); + + if (createResult.success) { + const insightId = createResult.data.id; + createdResources.insights.push(insightId); + + // Get + const getResult = await client + .insights({ projectId: testProjectId }) + .get({ insightId }); + + if (!getResult.success) { + console.error("Failed to get insight:", (getResult as any).error); + } + + expect(getResult.success).toBe(true); + + if (getResult.success) { + expect(getResult.data.name).toBe("Test Insight"); + } + + // Update + const updateResult = await client.insights({ projectId: testProjectId }).update({ + insightId, + data: { + name: "Updated Test Insight", + }, + }); + expect(updateResult.success).toBe(true); + + // Delete will be handled by afterEach cleanup + } + }); + }); + + describe("Dashboards API", () => { + it("should list dashboards", async () => { + const result = await client.dashboards({ projectId: testProjectId }).list(); + + expect(result.success).toBe(true); + + if (result.success) { + expect(Array.isArray(result.data)).toBe(true); + for (const dashboard of result.data) { + expect(dashboard).toHaveProperty("id"); + expect(dashboard).toHaveProperty("name"); + expect(typeof dashboard.id).toBe("number"); + expect(typeof dashboard.name).toBe("string"); + } + } + }); + + it("should create, get, update, and delete a dashboard", async () => { + const createResult = await client.dashboards({ projectId: testProjectId }).create({ + data: { + name: "Test Dashboard", + description: "Test dashboard for API tests", + }, + }); + + expect(createResult.success).toBe(true); + + if (createResult.success) { + const dashboardId = createResult.data.id; + createdResources.dashboards.push(dashboardId); + + // Get + const getResult = await client + .dashboards({ projectId: testProjectId }) + .get({ dashboardId }); + expect(getResult.success).toBe(true); + + if (getResult.success) { + expect(getResult.data.name).toBe("Test Dashboard"); + } + + // Update + const updateResult = await client.dashboards({ projectId: testProjectId }).update({ + dashboardId, + data: { + name: "Updated Test Dashboard", + }, + }); + expect(updateResult.success).toBe(true); + + // Delete will be handled by afterEach cleanup + } + }); + }); + + describe("Query API", () => { + it("should execute error tracking query", async () => { + const errorQuery = { + kind: "ErrorTrackingQuery", + orderBy: "occurrences", + dateRange: { + date_from: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(), + date_to: new Date().toISOString(), + }, + volumeResolution: 1, + orderDirection: "DESC", + filterTestAccounts: true, + status: "active", + }; + + const result = await client + .query({ projectId: testProjectId }) + .execute({ queryBody: errorQuery }); + + if (!result.success) { + console.error("Failed to execute error query:", (result as any).error); + } + + expect(result.success).toBe(true); + + if (result.success) { + expect(result.data).toHaveProperty("results"); + expect(Array.isArray(result.data.results)).toBe(true); + } + }); + + it("should execute trends query for LLM costs", async () => { + const trendsQuery = { + kind: "TrendsQuery", + dateRange: { + date_from: "-6d", + date_to: null, + }, + filterTestAccounts: true, + series: [ + { + event: "$ai_generation", + name: "$ai_generation", + math: "sum", + math_property: "$ai_total_cost_usd", + kind: "EventsNode", + }, + ], + breakdownFilter: { + breakdown_type: "event", + breakdown: "$ai_model", + }, + }; + + const result = await client + .query({ projectId: testProjectId }) + .execute({ queryBody: trendsQuery }); + + expect(result.success).toBe(true); + + if (result.success) { + expect(result.data).toHaveProperty("results"); + expect(Array.isArray(result.data.results)).toBe(true); + } + }); + }); + + describe("Users API", () => { + it("should get current user", async () => { + const result = await client.users().me(); + + expect(result.success).toBe(true); + + if (result.success) { + expect(result.data).toHaveProperty("distinctId"); + expect(typeof result.data.distinctId).toBe("string"); + } + }); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..c69ab20 --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,5 @@ +import { config } from "dotenv"; +import { resolve } from "node:path"; + +// Load .env.test file +config({ path: resolve(process.cwd(), ".env.test") }); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..0b9fcf3 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + testTimeout: 30000, + setupFiles: ["tests/setup.ts"], + include: ["tests/**/*.test.ts"], + exclude: ["node_modules/**", "dist/**"], + }, + resolve: { + alias: { + "@": "/src", + }, + }, +});