diff --git a/packages/agiled/client.ts b/packages/agiled/client.ts new file mode 100644 index 000000000..c0e485252 --- /dev/null +++ b/packages/agiled/client.ts @@ -0,0 +1,116 @@ +import type { + ApiRequestOptions, + OpenAPIConfig, + RateLimitConfig, +} from 'corsair/http'; +import { ApiError, request } from 'corsair/http'; + +export class AgiledAPIError extends Error { + constructor( + message: string, + public readonly code?: string, + ) { + super(message); + this.name = 'AgiledAPIError'; + } +} + +export const AGILED_API_BASE = 'https://app.agiled.app/api/public/v1'; + +const READ_MAX_ATTEMPTS = 6; + +const NO_RETRY: RateLimitConfig = { + enabled: true, + maxRetries: 0, + initialRetryDelay: 0, + backoffMultiplier: 1, + headerNames: { + retryAfter: 'retry-after', + }, +}; + +function isRetryableAgiledError(error: unknown): error is ApiError { + if (!(error instanceof ApiError) || error.status === undefined) { + return false; + } + return error.status === 429 || error.status >= 500; +} + +function retryDelayMs(error: ApiError, attempt: number): number { + if (typeof error.retryAfter === 'number' && error.retryAfter >= 0) { + return error.retryAfter; + } + return 2 ** attempt * 1000; +} + +export async function makeAgiledRequest( + endpoint: string, + apiKey: string, + options: { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + body?: Record; + query?: Record; + retries?: boolean; + } = {}, +): Promise { + const { method = 'GET', body, query, retries = method === 'GET' } = options; + + const config: OpenAPIConfig = { + BASE: AGILED_API_BASE, + VERSION: '1.0.0', + WITH_CREDENTIALS: false, + CREDENTIALS: 'omit', + TOKEN: apiKey, + HEADERS: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + }; + + const requestOptions: ApiRequestOptions = { + method, + url: endpoint, + body: + method === 'POST' || method === 'PUT' || method === 'PATCH' + ? body + : undefined, + mediaType: 'application/json; charset=utf-8', + query: method === 'GET' ? query : undefined, + }; + + const send = async (): Promise => { + try { + return await request(config, requestOptions, { + rateLimitConfig: NO_RETRY, + }); + } catch (error) { + if (error instanceof ApiError) { + throw error; + } + if (error instanceof Error) { + throw new AgiledAPIError(error.message); + } + throw new AgiledAPIError('Unknown error'); + } + }; + + if (!retries) { + return await send(); + } + + let lastError: unknown; + for (let attempt = 0; attempt < READ_MAX_ATTEMPTS; attempt++) { + try { + return await send(); + } catch (error) { + lastError = error; + if (!isRetryableAgiledError(error) || attempt === READ_MAX_ATTEMPTS - 1) { + throw error; + } + await new Promise((resolve) => + setTimeout(resolve, retryDelayMs(error, attempt)), + ); + } + } + throw lastError; +} diff --git a/packages/agiled/endpoints.test.ts b/packages/agiled/endpoints.test.ts new file mode 100644 index 000000000..cad66a114 --- /dev/null +++ b/packages/agiled/endpoints.test.ts @@ -0,0 +1,200 @@ +import { AuthMissingError } from 'corsair/core'; +import { ApiError, request } from 'corsair/http'; +import { makeAgiledRequest } from './client'; +import { errorHandlers } from './error-handlers'; +import type { AgiledContext } from './index'; +import { agiled, agiledEndpointSchemas } from './index'; + +jest.mock('corsair/http', () => { + const original = jest.requireActual('corsair/http'); + return { + ...original, + request: jest.fn(), + }; +}); + +const mockRequest = request as jest.Mock; + +const mockCtx = { + key: 'agiled_test_key', + $getAccountId: () => 'test-account-id', + options: {}, + keys: { + get_api_key: jest.fn().mockResolvedValue('agiled_test_key'), + }, + logEvent: jest.fn(), + database: {}, +} as unknown as AgiledContext; + +describe('Agiled plugin registry', () => { + const plugin = agiled(); + const endpoints = plugin.endpoints!; + + it('registers contacts.list with schemas and metadata', () => { + expect(plugin.id).toBe('agiled'); + expect(endpoints.contacts.list).toBeDefined(); + expect(plugin.webhooks).toEqual({}); + expect(Object.keys(agiledEndpointSchemas)).toEqual(['contacts.list']); + expect(plugin.endpointMeta?.['contacts.list']?.riskLevel).toBe('read'); + }); + + it('throws AuthMissingError when no API key is configured', async () => { + await expect( + plugin.keyBuilder!( + { + ...mockCtx, + authType: 'api_key', + keys: { + get_api_key: jest.fn().mockResolvedValue(undefined), + }, + } as unknown as Parameters>[0], + 'endpoint', + ), + ).rejects.toBeInstanceOf(AuthMissingError); + }); + + it('does not match incoming webhooks', () => { + expect( + plugin.pluginWebhookMatcher?.({ + headers: { 'x-agiled-signature': 'anything' }, + body: JSON.stringify({ type: 'example' }), + }), + ).toBe(false); + }); +}); + +describe('Agiled client error wrapping and retries', () => { + beforeEach(() => { + mockRequest.mockReset(); + }); + + it('rethrows ApiError without dropping status and retry metadata', async () => { + const apiError = new ApiError( + { method: 'GET', url: 'https://app.agiled.app/api/public/v1/contacts' }, + { + ok: false, + status: 429, + statusText: 'Too Many Requests', + url: 'https://app.agiled.app/api/public/v1/contacts', + body: { message: 'Rate limit exceeded' }, + }, + 'Too Many Requests', + ); + mockRequest.mockRejectedValue(apiError); + + await expect( + makeAgiledRequest('/contacts', 'test-key', { + method: 'GET', + retries: false, + }), + ).rejects.toThrow(apiError); + }); + + it('retries GET 429s inside the client', async () => { + const apiError = new ApiError( + { method: 'GET', url: 'https://app.agiled.app/api/public/v1/contacts' }, + { + ok: false, + status: 429, + statusText: 'Too Many Requests', + url: 'https://app.agiled.app/api/public/v1/contacts', + body: { message: 'Rate limit exceeded' }, + }, + 'Too Many Requests', + { retryAfter: 0 }, + ); + mockRequest + .mockRejectedValueOnce(apiError) + .mockResolvedValueOnce({ data: [] }); + + const result = await makeAgiledRequest('/contacts', 'test-key', { + method: 'GET', + }); + expect(result).toEqual({ data: [] }); + expect(mockRequest).toHaveBeenCalledTimes(2); + }); + + it('does not retry POST requests', async () => { + const apiError = new ApiError( + { method: 'POST', url: 'https://app.agiled.app/api/public/v1/contacts' }, + { + ok: false, + status: 429, + statusText: 'Too Many Requests', + url: 'https://app.agiled.app/api/public/v1/contacts', + body: { message: 'Rate limit exceeded' }, + }, + 'Too Many Requests', + ); + mockRequest.mockRejectedValue(apiError); + + await expect( + makeAgiledRequest('/contacts', 'test-key', { + method: 'POST', + body: { first_name: 'Ada' }, + }), + ).rejects.toThrow(apiError); + expect(mockRequest).toHaveBeenCalledTimes(1); + }); +}); + +describe('Agiled binder error handlers', () => { + it('keeps 429 binder retries at zero', async () => { + const apiError = new ApiError( + { method: 'GET', url: 'https://app.agiled.app/api/public/v1/contacts' }, + { + ok: false, + status: 429, + statusText: 'Too Many Requests', + url: 'https://app.agiled.app/api/public/v1/contacts', + body: {}, + }, + 'Too Many Requests', + ); + expect(errorHandlers.RATE_LIMIT_ERROR.match(apiError)).toBe(true); + await expect( + errorHandlers.RATE_LIMIT_ERROR.handler(apiError), + ).resolves.toMatchObject({ maxRetries: 0 }); + }); +}); + +describe('Agiled contacts.list', () => { + const endpoints = agiled().endpoints!; + + beforeEach(() => { + mockRequest.mockReset(); + }); + + it('GETs /contacts with page and limit', async () => { + mockRequest.mockResolvedValue({ + data: [{ id: 1, first_name: 'Ada', email: 'ada@example.com' }], + current_page: 2, + last_page: 4, + }); + + const result = await endpoints.contacts.list(mockCtx, { + page: 2, + limit: 25, + }); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + BASE: 'https://app.agiled.app/api/public/v1', + TOKEN: 'agiled_test_key', + HEADERS: expect.not.objectContaining({ + Authorization: 'Bearer ${apikey}', + }), + }), + expect.objectContaining({ + method: 'GET', + url: '/contacts', + query: { page: 2, limit: 25 }, + }), + expect.objectContaining({ + rateLimitConfig: expect.objectContaining({ maxRetries: 0 }), + }), + ); + expect(result.data).toHaveLength(1); + expect(result.current_page).toBe(2); + }); +}); diff --git a/packages/agiled/endpoints/contacts.ts b/packages/agiled/endpoints/contacts.ts new file mode 100644 index 000000000..4e4263277 --- /dev/null +++ b/packages/agiled/endpoints/contacts.ts @@ -0,0 +1,17 @@ +import type { AgiledEndpoints } from '..'; +import { makeAgiledRequest } from '../client'; +import type { AgiledEndpointOutputs } from './types'; + +export const list: AgiledEndpoints['listContacts'] = async (ctx, input) => { + return makeAgiledRequest( + '/contacts', + ctx.key, + { + method: 'GET', + query: { + page: input.page, + limit: input.limit, + }, + }, + ); +}; diff --git a/packages/agiled/endpoints/index.ts b/packages/agiled/endpoints/index.ts new file mode 100644 index 000000000..f328b3485 --- /dev/null +++ b/packages/agiled/endpoints/index.ts @@ -0,0 +1,7 @@ +import { list } from './contacts'; + +export const Contacts = { + list, +}; + +export * from './types'; diff --git a/packages/agiled/endpoints/types.ts b/packages/agiled/endpoints/types.ts new file mode 100644 index 000000000..a375b2060 --- /dev/null +++ b/packages/agiled/endpoints/types.ts @@ -0,0 +1,40 @@ +import { z } from 'zod'; + +const ContactSchema = z.object({ + id: z.number().or(z.string()), + first_name: z.string(), + last_name: z.string().optional(), + email: z.string().email().optional(), + phone: z.string().nullable().optional(), +}); + +const ListContactsInputSchema = z.object({ + page: z.number().optional(), + limit: z.number().optional(), +}); + +export type ListContactsInput = z.infer; + +const ListContactsResponseSchema = z.object({ + data: z.array(ContactSchema), + current_page: z.number().optional(), + last_page: z.number().optional(), +}); + +export type ListContactsResponse = z.infer; + +export type AgiledEndpointInputs = { + listContacts: ListContactsInput; +}; + +export type AgiledEndpointOutputs = { + listContacts: ListContactsResponse; +}; + +export const AgiledEndpointInputSchemas = { + listContacts: ListContactsInputSchema, +} as const; + +export const AgiledEndpointOutputSchemas = { + listContacts: ListContactsResponseSchema, +} as const; diff --git a/packages/agiled/error-handlers.ts b/packages/agiled/error-handlers.ts new file mode 100644 index 000000000..217f52255 --- /dev/null +++ b/packages/agiled/error-handlers.ts @@ -0,0 +1,31 @@ +import type { CorsairErrorHandler } from 'corsair/core'; +import { ApiError } from 'corsair/http'; + +export const errorHandlers = { + RATE_LIMIT_ERROR: { + match: (error: Error) => { + if (error instanceof ApiError && error.status === 429) return true; + const msg = error.message.toLowerCase(); + return msg.includes('rate_limited') || msg.includes('429'); + }, + handler: async (error: Error) => { + let retryAfterMs: number | undefined; + if (error instanceof ApiError && error.retryAfter !== undefined) { + retryAfterMs = error.retryAfter; + } + return { maxRetries: 0, headersRetryAfterMs: retryAfterMs }; + }, + }, + AUTH_ERROR: { + match: (error: Error) => { + if (error instanceof ApiError && error.status === 401) return true; + const msg = error.message.toLowerCase(); + return msg.includes('unauthorized') || msg.includes('invalid_auth'); + }, + handler: async () => ({ maxRetries: 0 }), + }, + DEFAULT: { + match: () => true, + handler: async () => ({ maxRetries: 0 }), + }, +} satisfies CorsairErrorHandler; diff --git a/packages/agiled/index.ts b/packages/agiled/index.ts new file mode 100644 index 000000000..547dbfb34 --- /dev/null +++ b/packages/agiled/index.ts @@ -0,0 +1,143 @@ +import type { + AuthTypes, + BindEndpoints, + CorsairEndpoint, + CorsairErrorHandler, + CorsairPlugin, + CorsairPluginContext, + KeyBuilderContext, + PickAuth, + PluginAuthConfig, + PluginPermissionsConfig, + RequiredPluginEndpointMeta, + RequiredPluginEndpointSchemas, +} from 'corsair/core'; +import { AuthMissingError } from 'corsair/core'; +import { Contacts } from './endpoints'; +import type { + AgiledEndpointInputs, + AgiledEndpointOutputs, +} from './endpoints/types'; +import { + AgiledEndpointInputSchemas, + AgiledEndpointOutputSchemas, +} from './endpoints/types'; +import { errorHandlers } from './error-handlers'; +import { AgiledSchema } from './schema'; + +export type AgiledPluginOptions = { + authType?: PickAuth<'api_key'>; + key?: string; + hooks?: InternalAgiledPlugin['hooks']; + errorHandlers?: CorsairErrorHandler; + permissions?: PluginPermissionsConfig; +}; + +export type AgiledContext = CorsairPluginContext< + typeof AgiledSchema, + AgiledPluginOptions +>; + +export type AgiledKeyBuilderContext = KeyBuilderContext; + +export type AgiledBoundEndpoints = BindEndpoints; + +type AgiledEndpoint = CorsairEndpoint< + AgiledContext, + AgiledEndpointInputs[K], + AgiledEndpointOutputs[K] +>; + +export type AgiledEndpoints = { + listContacts: AgiledEndpoint<'listContacts'>; +}; + +const agiledEndpointsNested = { + contacts: { + list: Contacts.list, + }, +} as const; + +export const agiledEndpointSchemas = { + 'contacts.list': { + input: AgiledEndpointInputSchemas.listContacts, + output: AgiledEndpointOutputSchemas.listContacts, + }, +} as const satisfies RequiredPluginEndpointSchemas< + typeof agiledEndpointsNested +>; + +const defaultAuthType: AuthTypes = 'api_key' as const; + +const agiledEndpointMeta = { + 'contacts.list': { + riskLevel: 'read', + description: 'List contacts from an Agiled workspace', + }, +} as const satisfies RequiredPluginEndpointMeta; + +export const agiledAuthConfig = { + api_key: { + account: ['tenant_external_id'] as const, + }, +} as const satisfies PluginAuthConfig; + +export type BaseAgiledPlugin = CorsairPlugin< + 'agiled', + typeof AgiledSchema, + typeof agiledEndpointsNested, + {}, + T, + typeof defaultAuthType +>; + +export type InternalAgiledPlugin = BaseAgiledPlugin; + +export type ExternalAgiledPlugin = + BaseAgiledPlugin; + +export function agiled( + incomingOptions: AgiledPluginOptions & T = {} as AgiledPluginOptions & T, +): ExternalAgiledPlugin { + const options = { + ...incomingOptions, + authType: incomingOptions.authType ?? defaultAuthType, + }; + return { + id: 'agiled', + authConfig: agiledAuthConfig, + schema: AgiledSchema, + options: options, + hooks: options.hooks, + endpoints: agiledEndpointsNested, + webhooks: {}, + endpointMeta: agiledEndpointMeta, + endpointSchemas: agiledEndpointSchemas, + pluginWebhookMatcher: () => false, + pluginTenantWebhookMatcher: () => null, + oauthWebhookTenantLinkResolver: () => null, + errorHandlers: { + ...errorHandlers, + ...options.errorHandlers, + }, + keyBuilder: async (ctx: AgiledKeyBuilderContext, source) => { + if (source === 'endpoint' && options.key) { + return options.key; + } + + if (source === 'endpoint' && ctx.authType === 'api_key') { + const res = await ctx.keys.get_api_key(); + if (res) return res; + } + + throw new AuthMissingError('agiled', 'api_key'); + }, + } satisfies InternalAgiledPlugin; +} + +export type { + AgiledEndpointInputs, + AgiledEndpointOutputs, + ListContactsInput, + ListContactsResponse, +} from './endpoints/types'; diff --git a/packages/agiled/jest.config.cjs b/packages/agiled/jest.config.cjs new file mode 100644 index 000000000..8c6218f64 --- /dev/null +++ b/packages/agiled/jest.config.cjs @@ -0,0 +1,55 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: [''], + testMatch: [ + '**/*.test.ts', + '**/tests/**/*.test.ts', + '**/plugins/**/*.test.ts', + '**/setup/**/*.test.ts', + ], + collectCoverageFrom: [ + '**/*.ts', + '!**/*.d.ts', + '!**/node_modules/**', + '!**/dist/**', + '!jest.config.ts', + '!tests/**', + ], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], + transform: { + '^.+\\.yaml$': '/../corsair/jest-yaml-transform.cjs', + '^.+\\.ts$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + verbatimModuleSyntax: false, + module: 'ESNext', + moduleResolution: 'Bundler', + }, + }, + ], + '.*\\.js$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + }, + }, + ], + }, + moduleNameMapper: { + '^corsair/core$': '/../corsair/core.ts', + '^corsair/http$': '/../corsair/http.ts', + '^(\\.\\.?/.*)\\.js$': '$1', + }, + transformIgnorePatterns: ['node_modules/(?!.*uuid.*)'], + extensionsToTreatAsEsm: ['.ts'], + testTimeout: 30000, + verbose: true, +}; diff --git a/packages/agiled/package.json b/packages/agiled/package.json new file mode 100644 index 000000000..15a0832e1 --- /dev/null +++ b/packages/agiled/package.json @@ -0,0 +1,44 @@ +{ + "name": "@corsair-dev/agiled", + "version": "0.1.0", + "description": "Agiled plugin for Corsair", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "dev-source": "./index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "rm -rf dist && tsc --build --force && tsup", + "typecheck": "tsc --noEmit", + "test": "jest" + }, + "peerDependencies": { + "corsair": ">=0.1.0", + "zod": "^4.1.13" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "corsair": "workspace:*", + "jest": "^29.7.0", + "ts-jest": "^29.4.9", + "tsup": "^8.0.1", + "typescript": "catalog:", + "zod": "^4.1.13" + }, + "keywords": [ + "corsair", + "agiled", + "plugin" + ], + "author": "", + "license": "Apache-2.0", + "files": [ + "dist" + ] +} diff --git a/packages/agiled/readme.md b/packages/agiled/readme.md new file mode 100644 index 000000000..9aa43fcfc --- /dev/null +++ b/packages/agiled/readme.md @@ -0,0 +1,13 @@ +# Agiled Integration + +This plugin integrates the [Agiled API](https://docs.agiled.app/docs/developers/api-keys/) with Corsair. It enables programmatic management of business operations such as CRM, HR, and project management. + +## Authentication + +This integration uses the `api_key` authentication method. You must provide an Agiled API Key, which you can generate from your workspace under **Settings > API Settings**. + +## Endpoints + +Currently supported endpoints: + +- `contacts.list`: Fetch a list of contacts from your Agiled CRM. diff --git a/packages/agiled/schema.test.ts b/packages/agiled/schema.test.ts new file mode 100644 index 000000000..e6bd758e2 --- /dev/null +++ b/packages/agiled/schema.test.ts @@ -0,0 +1,12 @@ +import { AgiledSchema } from './schema'; + +describe('Agiled schema', () => { + it('declares a semver version', () => { + expect(AgiledSchema.version).toBeDefined(); + expect(AgiledSchema.version).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it('declares an empty entities map', () => { + expect(AgiledSchema.entities).toEqual({}); + }); +}); diff --git a/packages/agiled/schema/index.ts b/packages/agiled/schema/index.ts new file mode 100644 index 000000000..3191bcba6 --- /dev/null +++ b/packages/agiled/schema/index.ts @@ -0,0 +1,4 @@ +export const AgiledSchema = { + version: '1.0.0', + entities: {}, +} as const; diff --git a/packages/agiled/tsconfig.json b/packages/agiled/tsconfig.json new file mode 100644 index 000000000..15e507a13 --- /dev/null +++ b/packages/agiled/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["esnext"], + "types": ["node", "jest"], + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "./dist", + "rootDir": "./", + "composite": true, + "incremental": true, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "skipLibCheck": true + }, + "include": ["./**/*"], + "exclude": ["dist", "node_modules"], + "references": [] +} diff --git a/packages/agiled/tsup.config.ts b/packages/agiled/tsup.config.ts new file mode 100644 index 000000000..3ec221e23 --- /dev/null +++ b/packages/agiled/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + clean: false, + dts: false, + format: ['esm'], + target: 'esnext', + platform: 'node', + bundle: true, + splitting: true, + minify: true, + outDir: 'dist', + external: ['corsair', 'zod'], + entry: ['index.ts'], +}); diff --git a/packages/corsair/core/constants.ts b/packages/corsair/core/constants.ts index b5dca6fd6..8670d1e47 100644 --- a/packages/corsair/core/constants.ts +++ b/packages/corsair/core/constants.ts @@ -26,6 +26,7 @@ export const BaseProviders = [ 'agentmail', 'agentql', 'agenty', + 'agiled', 'agilitycms', 'ahrefs', 'aimlapi', @@ -214,6 +215,7 @@ export const ProviderDisplayNames = { agentmail: 'AgentMail', agentql: 'AgentQL', agenty: 'Agenty', + agiled: 'Agiled', agilitycms: 'Agility CMS', ahrefs: 'Ahrefs', aimlapi: 'AI/ML API', @@ -409,6 +411,7 @@ export type AllProviders = | 'agentmail' | 'agentql' | 'agenty' + | 'agiled' | 'agilitycms' | 'ahrefs' | 'aimlapi' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd3d10ddc..e805381f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -617,6 +617,30 @@ importers: specifier: 4.4.3 version: 4.4.3 + packages/agiled: + devDependencies: + '@types/jest': + specifier: ^29.5.14 + version: 29.5.14 + corsair: + specifier: workspace:* + version: link:../corsair + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) + ts-jest: + specifier: ^29.4.9 + version: 29.4.9(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.27.0)(jest-util@30.4.1)(jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(typescript@5.9.3) + tsup: + specifier: ^8.0.1 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.4)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: 'catalog:' + version: 5.9.3 + zod: + specifier: 4.4.3 + version: 4.4.3 + packages/agilitycms: devDependencies: '@types/jest':