diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index afb6dce..a0a5557 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,10 +84,7 @@ jobs: - name: Run tests with coverage run: npm run test:coverage - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5 with: - file: ./coverage/lcov.info - flags: unittests - name: codecov-umbrella - fail_ci_if_error: false \ No newline at end of file + token: ${{ secrets.CODECOV_TOKEN }} \ No newline at end of file diff --git a/docs/ERROR_REFERENCE.md b/docs/ERROR_REFERENCE.md new file mode 100644 index 0000000..186b1e9 --- /dev/null +++ b/docs/ERROR_REFERENCE.md @@ -0,0 +1,213 @@ +# ContextMesh CLI Error Reference + +This document provides a comprehensive guide to error messages you might encounter while using the ContextMesh CLI and how to resolve them. + +## Error Types + +### Validation Errors (VALIDATION_ERROR) + +These errors occur when your connector manifest (`connector.mcp.json`) doesn't meet the required schema or contains invalid data. + +#### Common Validation Errors + +1. **Missing Required Property** + ``` + āŒ Manifest validation failed: Missing required property: _contextmesh + Field: root + šŸ’” Suggestion: Add "_contextmesh" section with version, tags, language, and repo + ``` + **Solution**: Add the missing property to your manifest file. + +2. **Invalid Format** + ``` + āŒ Manifest validation failed: Invalid format: must match pattern "^[a-z0-9-]+$" + Field: id + Line: 3, Column: 3 + šŸ’” Suggestion: Connector ID must contain only lowercase letters, numbers, and hyphens + ``` + **Solution**: Fix the format according to the suggestion. In this case, use only lowercase letters, numbers, and hyphens. + +3. **Invalid Version Format** + ``` + āŒ Invalid version format: 1.0 + Field: _contextmesh.version + šŸ’” Suggestion: Use semantic versioning format (e.g., "1.0.0") + ``` + **Solution**: Use proper semantic versioning with three parts: MAJOR.MINOR.PATCH + +### Network Errors (NETWORK_ERROR) + +These errors occur when the CLI cannot communicate with the ContextMesh registry. + +#### HTTP Status Codes + +1. **401 Unauthorized** + ``` + āŒ HTTP 401: Authentication failed: Invalid or expired token + Endpoint: POST https://api.contextmesh.io/v1/connectors + šŸ’” Suggestion: Check your CONTEXTMESH_TOKEN or use --token flag with a valid token + ``` + **Solution**: + - Set a valid token: `export CONTEXTMESH_TOKEN="your-token"` + - Or use the flag: `contextmesh publish --token "your-token"` + +2. **409 Conflict** + ``` + āŒ HTTP 409: Conflict: Resource already exists + šŸ’” Suggestion: This version may already be published. Try incrementing the version number + ``` + **Solution**: Update the version number in your manifest's `_contextmesh.version` field. + +3. **413 Payload Too Large** + ``` + āŒ HTTP 413: Payload too large: Connector package exceeds size limit + šŸ’” Suggestion: Reduce the size of your connector package (check for large files) + ``` + **Solution**: Check your connector directory for large files that shouldn't be included. Add them to `.gitignore`. + +4. **429 Rate Limit** + ``` + āŒ HTTP 429: Rate limit exceeded + ⟳ This error may be temporary. You can try again. Wait 60 seconds before retrying. + ``` + **Solution**: Wait for the specified time and try again. The CLI will automatically retry if possible. + +#### Connection Errors + +1. **Connection Refused** + ``` + āŒ Connection refused: Cannot reach the registry server + šŸ’” Suggestion: Check your internet connection and the registry URL + ``` + **Solution**: + - Check your internet connection + - Verify the registry URL is correct + - Check if you're behind a proxy + +2. **Timeout** + ``` + āŒ Request timeout: Server took too long to respond + ⟳ This error may be temporary. You can try again. + ``` + **Solution**: Try again. If the problem persists, check your network connection. + +### Authentication Errors (AUTH_ERROR) + +These errors relate to authentication and authorization issues. + +1. **Missing Token** + ``` + āŒ No authentication token provided + Token present: No + šŸ’” Suggestion: Set CONTEXTMESH_TOKEN environment variable or use --token flag + Example: export CONTEXTMESH_TOKEN="your-token-here" + Or: contextmesh publish --token "your-token-here" + ``` + **Solution**: Obtain a token from https://app.contextmesh.io/settings/tokens and set it as shown. + +2. **Expired Token** + ``` + āŒ Authentication token has expired + Token present: Yes + šŸ’” Suggestion: Your token has expired. Generate a new one: + 1. Visit https://app.contextmesh.io/settings/tokens + 2. Generate a new API token + 3. Update your CONTEXTMESH_TOKEN environment variable + ``` + **Solution**: Generate a new token and update your environment variable. + +### File System Errors (FILESYSTEM_ERROR) + +These errors occur when the CLI cannot access or manipulate files on your system. + +1. **File Not Found** + ``` + āŒ File not found: /path/to/connector.mcp.json + Path: /path/to/connector.mcp.json + Operation: read + Error code: ENOENT + šŸ’” Suggestion: Make sure the file exists and the path is correct + ``` + **Solution**: Verify the file exists or run the command from the correct directory. + +2. **Permission Denied** + ``` + āŒ Permission denied: Cannot write /protected/path + Path: /protected/path + Operation: write + Error code: EACCES + šŸ’” Suggestion: Check file permissions or run with appropriate privileges + ``` + **Solution**: + - Check file permissions: `ls -la ` + - Change permissions if needed: `chmod 644 ` + - Run with appropriate user privileges + +3. **Invalid JSON** + ``` + āŒ Invalid JSON in manifest file: Unexpected token } in JSON at position 245 + Path: connector.mcp.json + Operation: read + šŸ’” Suggestion: Check for syntax errors in your connector.mcp.json file + ``` + **Solution**: Use a JSON validator or editor to fix syntax errors in your manifest. + +## Debugging Tips + +### Verbose Mode + +Use the `-v` or `--verbose` flag to get detailed error information including stack traces: + +```bash +contextmesh publish -v +``` + +### Common Solutions + +1. **Check Your Manifest** + - Validate JSON syntax using a JSON validator + - Ensure all required fields are present + - Check that values match the expected format + +2. **Network Issues** + - Verify your internet connection + - Check if you're behind a corporate proxy + - Try using a different network + +3. **Authentication** + - Ensure your token hasn't expired + - Verify the token has the correct permissions + - Try generating a new token + +4. **File System** + - Run commands from the connector directory + - Check file and directory permissions + - Ensure you have enough disk space + +## Exit Codes + +The CLI uses specific exit codes for different error types: + +- `0`: Success +- `1`: General error +- `2`: Authentication error +- `3`: Validation error +- `4`: Network error +- `5`: File system error + +You can use these in scripts to handle specific error cases: + +```bash +contextmesh publish +if [ $? -eq 2 ]; then + echo "Authentication failed. Please check your token." +fi +``` + +## Getting Help + +If you continue to experience issues: + +1. Use verbose mode (`-v`) to get more details +2. Check the [ContextMesh documentation](https://docs.contextmesh.io) +3. Report issues at https://github.com/contextmesh/cli/issues \ No newline at end of file diff --git a/src/__tests__/commands/publish.test.ts b/src/__tests__/commands/publish.test.ts new file mode 100644 index 0000000..db7a6b7 --- /dev/null +++ b/src/__tests__/commands/publish.test.ts @@ -0,0 +1,86 @@ +import { publishCommand } from '../../commands/publish'; +import { handleError } from '../../errors'; +import { validateManifest } from '../../utils/validator'; +import { publishConnector } from '../../utils/publisher'; +import { createManifestIfMissing } from '../../utils/manifest'; + +// Mock dependencies +jest.mock('../../utils/validator'); +jest.mock('../../utils/publisher'); +jest.mock('../../utils/manifest'); +jest.mock('../../errors'); +jest.mock('ora', () => { + return jest.fn(() => ({ + start: jest.fn().mockReturnThis(), + succeed: jest.fn().mockReturnThis(), + fail: jest.fn().mockReturnThis() + })); +}); + +const mockValidateManifest = validateManifest as jest.MockedFunction; +const mockPublishConnector = publishConnector as jest.MockedFunction; +const mockCreateManifestIfMissing = createManifestIfMissing as jest.MockedFunction; +const mockHandleError = handleError as jest.MockedFunction; + +// Mock console methods +const mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(); + +describe('publishCommand', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + mockConsoleLog.mockRestore(); + }); + + describe('command structure', () => { + it('should have correct command description', () => { + expect(publishCommand.description()).toBe('Publish a connector to the ContextMesh registry'); + }); + + it('should have verbose option', () => { + const verboseOption = publishCommand.options.find(opt => opt.long === '--verbose'); + expect(verboseOption).toBeDefined(); + expect(verboseOption?.description).toBe('Show detailed error information'); + }); + + it('should have registry option', () => { + const registryOption = publishCommand.options.find(opt => opt.long === '--registry'); + expect(registryOption).toBeDefined(); + expect(registryOption?.description).toBe('Registry URL'); + }); + + it('should have token option', () => { + const tokenOption = publishCommand.options.find(opt => opt.long === '--token'); + expect(tokenOption).toBeDefined(); + expect(tokenOption?.description).toBe('Authentication token'); + }); + + it('should have dry-run option', () => { + const dryRunOption = publishCommand.options.find(opt => opt.long === '--dry-run'); + expect(dryRunOption).toBeDefined(); + expect(dryRunOption?.description).toBe('Perform a dry run without uploading'); + }); + + it('should be properly configured with all required options', () => { + // Test that the command has been configured with the right structure + expect(publishCommand.name()).toBe('publish'); + expect(publishCommand.options).toHaveLength(4); // registry, token, dry-run, verbose + }); + }); + + // Test error handling functions directly + describe('error handling integration', () => { + it('should import and have access to handleError function', () => { + expect(mockHandleError).toBeDefined(); + expect(typeof mockHandleError).toBe('function'); + }); + + it('should import validation utilities', () => { + expect(mockValidateManifest).toBeDefined(); + expect(mockCreateManifestIfMissing).toBeDefined(); + expect(mockPublishConnector).toBeDefined(); + }); + }); +}); \ No newline at end of file diff --git a/src/__tests__/errors/auth.test.ts b/src/__tests__/errors/auth.test.ts new file mode 100644 index 0000000..96a8cb7 --- /dev/null +++ b/src/__tests__/errors/auth.test.ts @@ -0,0 +1,108 @@ +import { AuthenticationError } from '../../errors/auth'; + +describe('AuthenticationError', () => { + describe('constructor', () => { + it('should create authentication error', () => { + const error = new AuthenticationError('Auth failed'); + + expect(error.message).toBe('Auth failed'); + expect(error.code).toBe('AUTH_ERROR'); + expect(error.name).toBe('AuthenticationError'); + }); + + it('should include auth details', () => { + const error = new AuthenticationError('Token invalid', { + tokenSource: 'environment', + tokenPresent: true + }); + + expect(error.details.tokenSource).toBe('environment'); + expect(error.details.tokenPresent).toBe(true); + }); + }); + + describe('static factory methods', () => { + describe('missingToken', () => { + it('should create missing token error', () => { + const error = AuthenticationError.missingToken(); + + expect(error.message).toBe('No authentication token provided'); + expect(error.details.tokenPresent).toBe(false); + expect(error.details.suggestion).toContain('CONTEXTMESH_TOKEN'); + expect(error.details.suggestion).toContain('--token flag'); + }); + }); + + describe('invalidToken', () => { + it('should create invalid token error with default source', () => { + const error = AuthenticationError.invalidToken(); + + expect(error.message).toBe('Invalid authentication token'); + expect(error.details.tokenSource).toBe('environment'); + expect(error.details.tokenPresent).toBe(true); + expect(error.details.suggestion).toContain('expired or invalid'); + expect(error.details.suggestion).toContain('app.contextmesh.io/settings/tokens'); + }); + + it('should create invalid token error with custom source', () => { + const error = AuthenticationError.invalidToken('flag'); + + expect(error.details.tokenSource).toBe('flag'); + }); + }); + + describe('expiredToken', () => { + it('should create expired token error', () => { + const error = AuthenticationError.expiredToken(); + + expect(error.message).toBe('Authentication token has expired'); + expect(error.details.tokenPresent).toBe(true); + expect(error.details.suggestion).toContain('expired'); + expect(error.details.suggestion).toContain('Generate a new one'); + }); + }); + + describe('insufficientPermissions', () => { + it('should create insufficient permissions error', () => { + const error = AuthenticationError.insufficientPermissions('publish connectors'); + + expect(error.message).toBe('Insufficient permissions to publish connectors'); + expect(error.details.tokenPresent).toBe(true); + expect(error.details.suggestion).toContain('write:connectors'); + expect(error.details.suggestion).toContain('proper permissions'); + }); + }); + }); + + describe('format', () => { + it('should format with token source', () => { + const error = new AuthenticationError('Auth failed', { + tokenSource: 'flag', + tokenPresent: true + }); + + const formatted = error.format(); + + expect(formatted).toContain('Auth failed'); + expect(formatted).toContain('Token source: flag'); + expect(formatted).toContain('Token present: Yes'); + }); + + it('should format without token', () => { + const error = new AuthenticationError('No token', { + tokenPresent: false + }); + + const formatted = error.format(); + + expect(formatted).toContain('Token present: No'); + }); + + it('should handle verbose mode', () => { + const error = new AuthenticationError('Auth error'); + const formatted = error.format(true); + + expect(formatted).toContain('Stack trace:'); + }); + }); +}); \ No newline at end of file diff --git a/src/__tests__/errors/base.test.ts b/src/__tests__/errors/base.test.ts new file mode 100644 index 0000000..fd7a155 --- /dev/null +++ b/src/__tests__/errors/base.test.ts @@ -0,0 +1,144 @@ +import { ContextMeshError, isContextMeshError, wrapError } from '../../errors/base'; + +describe('ContextMeshError', () => { + describe('constructor', () => { + it('should create error with basic properties', () => { + const error = new ContextMeshError('Test error', 'TEST_ERROR'); + + expect(error.message).toBe('Test error'); + expect(error.code).toBe('TEST_ERROR'); + expect(error.name).toBe('ContextMeshError'); + expect(error.timestamp).toBeInstanceOf(Date); + }); + + it('should include additional details', () => { + const error = new ContextMeshError('Test error', 'TEST_ERROR', { + field: 'testField', + line: 10, + column: 5, + suggestion: 'Try this instead' + }); + + expect(error.details.field).toBe('testField'); + expect(error.details.line).toBe(10); + expect(error.details.column).toBe(5); + expect(error.details.suggestion).toBe('Try this instead'); + }); + }); + + describe('format', () => { + it('should format basic error message', () => { + const error = new ContextMeshError('Test error', 'TEST_ERROR'); + const formatted = error.format(); + + expect(formatted).toBe('Test error'); + }); + + it('should include field information', () => { + const error = new ContextMeshError('Test error', 'TEST_ERROR', { + field: 'connector.id' + }); + const formatted = error.format(); + + expect(formatted).toContain('Test error'); + expect(formatted).toContain('Field: connector.id'); + }); + + it('should include line and column information', () => { + const error = new ContextMeshError('Test error', 'TEST_ERROR', { + line: 15, + column: 8 + }); + const formatted = error.format(); + + expect(formatted).toContain('Location: Line 15, Column 8'); + }); + + it('should include suggestion', () => { + const error = new ContextMeshError('Test error', 'TEST_ERROR', { + suggestion: 'Use lowercase letters only' + }); + const formatted = error.format(); + + expect(formatted).toContain('šŸ’” Suggestion: Use lowercase letters only'); + }); + + it('should include stack trace in verbose mode', () => { + const error = new ContextMeshError('Test error', 'TEST_ERROR'); + const formatted = error.format(true); + + expect(formatted).toContain('Stack trace:'); + expect(formatted).toContain('ContextMeshError'); + }); + }); + + describe('toJSON', () => { + it('should serialize error to JSON', () => { + const error = new ContextMeshError('Test error', 'TEST_ERROR', { + field: 'test' + }); + const json = error.toJSON(); + + expect(json.name).toBe('ContextMeshError'); + expect(json.code).toBe('TEST_ERROR'); + expect(json.message).toBe('Test error'); + expect(json.details.field).toBe('test'); + expect(json.timestamp).toBeInstanceOf(Date); + expect(json.stack).toBeDefined(); + }); + }); +}); + +describe('isContextMeshError', () => { + it('should return true for ContextMeshError instances', () => { + const error = new ContextMeshError('Test', 'TEST'); + expect(isContextMeshError(error)).toBe(true); + }); + + it('should return false for regular errors', () => { + const error = new Error('Test'); + expect(isContextMeshError(error)).toBe(false); + }); + + it('should return false for non-error objects', () => { + expect(isContextMeshError('string')).toBe(false); + expect(isContextMeshError(123)).toBe(false); + expect(isContextMeshError(null)).toBe(false); + expect(isContextMeshError(undefined)).toBe(false); + }); +}); + +describe('wrapError', () => { + it('should return existing ContextMeshError unchanged', () => { + const original = new ContextMeshError('Original', 'ORIGINAL'); + const wrapped = wrapError(original, 'NEW_CODE'); + + expect(wrapped).toBe(original); + expect(wrapped.code).toBe('ORIGINAL'); + }); + + it('should wrap regular Error', () => { + const original = new Error('Regular error'); + const wrapped = wrapError(original, 'WRAPPED_ERROR'); + + expect(wrapped).toBeInstanceOf(ContextMeshError); + expect(wrapped.message).toBe('Regular error'); + expect(wrapped.code).toBe('WRAPPED_ERROR'); + expect(wrapped.details.originalError).toBe(original); + }); + + it('should use custom message if provided', () => { + const original = new Error('Original'); + const wrapped = wrapError(original, 'WRAPPED', 'Custom message'); + + expect(wrapped.message).toBe('Custom message'); + }); + + it('should handle non-Error objects', () => { + const wrapped = wrapError('String error', 'STRING_ERROR'); + + expect(wrapped).toBeInstanceOf(ContextMeshError); + expect(wrapped.message).toBe('String error'); + expect(wrapped.code).toBe('STRING_ERROR'); + }); +}); \ No newline at end of file diff --git a/src/__tests__/errors/filesystem.test.ts b/src/__tests__/errors/filesystem.test.ts new file mode 100644 index 0000000..bdd5e2c --- /dev/null +++ b/src/__tests__/errors/filesystem.test.ts @@ -0,0 +1,210 @@ +import { FileSystemError } from '../../errors/filesystem'; + +describe('FileSystemError', () => { + describe('constructor', () => { + it('should create filesystem error', () => { + const error = new FileSystemError('File operation failed'); + + expect(error.message).toBe('File operation failed'); + expect(error.code).toBe('FILESYSTEM_ERROR'); + expect(error.name).toBe('FileSystemError'); + }); + + it('should include filesystem details', () => { + const error = new FileSystemError('Cannot read file', { + path: '/tmp/test.json', + operation: 'read', + errorCode: 'ENOENT' + }); + + expect(error.details.path).toBe('/tmp/test.json'); + expect(error.details.operation).toBe('read'); + expect(error.details.errorCode).toBe('ENOENT'); + }); + }); + + describe('fromNodeError', () => { + const createNodeError = (code: string, path?: string): Error & { code?: string; path?: string } => { + const error = new Error(`Mock ${code} error`) as Error & { code?: string; path?: string }; + error.code = code; + error.path = path; + return error; + }; + + it('should handle ENOENT (file not found)', () => { + const nodeError = createNodeError('ENOENT', '/tmp/missing.json'); + const error = FileSystemError.fromNodeError(nodeError); + + expect(error.message).toContain('File or directory not found: /tmp/missing.json'); + expect(error.details.errorCode).toBe('ENOENT'); + expect(error.details.suggestion).toContain('Check that the file exists'); + }); + + it('should handle EACCES (permission denied)', () => { + const nodeError = createNodeError('EACCES', '/root/protected.txt'); + const error = FileSystemError.fromNodeError(nodeError); + + expect(error.message).toContain('Permission denied: /root/protected.txt'); + expect(error.details.errorCode).toBe('EACCES'); + expect(error.details.suggestion).toContain('Check file permissions'); + }); + + it('should handle EISDIR (is directory)', () => { + const nodeError = createNodeError('EISDIR', '/tmp/directory'); + const error = FileSystemError.fromNodeError(nodeError); + + expect(error.message).toContain('Expected a file but found a directory'); + expect(error.details.errorCode).toBe('EISDIR'); + }); + + it('should handle ENOTDIR (not directory)', () => { + const nodeError = createNodeError('ENOTDIR', '/tmp/file.txt'); + const error = FileSystemError.fromNodeError(nodeError); + + expect(error.message).toContain('Expected a directory but found a file'); + expect(error.details.errorCode).toBe('ENOTDIR'); + }); + + it('should handle ENOSPC (no space)', () => { + const nodeError = createNodeError('ENOSPC'); + const error = FileSystemError.fromNodeError(nodeError); + + expect(error.message).toContain('No space left on device'); + expect(error.details.suggestion).toContain('Free up disk space'); + }); + + it('should handle EMFILE (too many files)', () => { + const nodeError = createNodeError('EMFILE'); + const error = FileSystemError.fromNodeError(nodeError); + + expect(error.message).toContain('Too many open files'); + expect(error.details.suggestion).toContain('Close some applications'); + }); + + it('should handle EEXIST (file exists)', () => { + const nodeError = createNodeError('EEXIST', '/tmp/existing.txt'); + const error = FileSystemError.fromNodeError(nodeError); + + expect(error.message).toContain('File already exists: /tmp/existing.txt'); + expect(error.details.suggestion).toContain('Remove the existing file'); + }); + + it('should handle EROFS (read-only filesystem)', () => { + const nodeError = createNodeError('EROFS'); + const error = FileSystemError.fromNodeError(nodeError); + + expect(error.message).toContain('Read-only file system'); + expect(error.details.suggestion).toContain('Cannot write to a read-only'); + }); + + it('should handle unknown error codes', () => { + const nodeError = createNodeError('UNKNOWN'); + nodeError.message = 'Custom error message'; + const error = FileSystemError.fromNodeError(nodeError); + + expect(error.message).toBe('Custom error message'); + expect(error.details.errorCode).toBe('UNKNOWN'); + }); + + it('should use provided path and operation', () => { + const nodeError = createNodeError('ENOENT'); + const error = FileSystemError.fromNodeError(nodeError, '/custom/path.txt', 'write'); + + expect(error.message).toContain('/custom/path.txt'); + expect(error.details.path).toBe('/custom/path.txt'); + expect(error.details.operation).toBe('write'); + }); + }); + + describe('static factory methods', () => { + describe('fileNotFound', () => { + it('should create file not found error', () => { + const error = FileSystemError.fileNotFound('/tmp/missing.json'); + + expect(error.message).toBe('File not found: /tmp/missing.json'); + expect(error.details.path).toBe('/tmp/missing.json'); + expect(error.details.operation).toBe('read'); + expect(error.details.errorCode).toBe('ENOENT'); + }); + }); + + describe('directoryNotFound', () => { + it('should create directory not found error', () => { + const error = FileSystemError.directoryNotFound('/tmp/missing-dir'); + + expect(error.message).toBe('Directory not found: /tmp/missing-dir'); + expect(error.details.path).toBe('/tmp/missing-dir'); + expect(error.details.operation).toBe('access'); + expect(error.details.suggestion).toContain('use "." for current directory'); + }); + }); + + describe('permissionDenied', () => { + it('should create permission denied error', () => { + const error = FileSystemError.permissionDenied('/root/file.txt', 'write'); + + expect(error.message).toBe('Permission denied: Cannot write /root/file.txt'); + expect(error.details.path).toBe('/root/file.txt'); + expect(error.details.operation).toBe('write'); + expect(error.details.errorCode).toBe('EACCES'); + }); + + it('should use default operation', () => { + const error = FileSystemError.permissionDenied('/root/file.txt'); + + expect(error.message).toContain('Cannot access'); + }); + }); + + describe('manifestNotFound', () => { + it('should create manifest not found error', () => { + const error = FileSystemError.manifestNotFound('/project'); + + expect(error.message).toBe('No connector.mcp.json found in directory'); + expect(error.details.path).toBe('/project/connector.mcp.json'); + expect(error.details.suggestion).toContain('Run this command from a connector directory'); + expect(error.details.suggestion).toContain('create a basic manifest'); + }); + }); + + describe('cannotCreateZip', () => { + it('should create zip creation error', () => { + const error = FileSystemError.cannotCreateZip(); + + expect(error.message).toBe('Failed to create connector archive'); + expect(error.details.operation).toBe('write'); + expect(error.details.suggestion).toContain('write permissions'); + }); + + it('should include reason if provided', () => { + const error = FileSystemError.cannotCreateZip('Archive too large'); + + expect(error.message).toBe('Failed to create connector archive: Archive too large'); + }); + }); + }); + + describe('format', () => { + it('should format with all details', () => { + const error = new FileSystemError('Operation failed', { + path: '/tmp/test.json', + operation: 'write', + errorCode: 'EACCES' + }); + + const formatted = error.format(); + + expect(formatted).toContain('Operation failed'); + expect(formatted).toContain('Path: /tmp/test.json'); + expect(formatted).toContain('Operation: write'); + expect(formatted).toContain('Error code: EACCES'); + }); + + it('should handle missing details gracefully', () => { + const error = new FileSystemError('Generic error'); + const formatted = error.format(); + + expect(formatted).toBe('Generic error'); + }); + }); +}); \ No newline at end of file diff --git a/src/__tests__/errors/network.test.ts b/src/__tests__/errors/network.test.ts new file mode 100644 index 0000000..1936883 --- /dev/null +++ b/src/__tests__/errors/network.test.ts @@ -0,0 +1,259 @@ +import { NetworkError } from '../../errors/network'; +import { AxiosError, AxiosHeaders } from 'axios'; + +describe('NetworkError', () => { + describe('constructor', () => { + it('should create network error', () => { + const error = new NetworkError('Network failed'); + + expect(error.message).toBe('Network failed'); + expect(error.code).toBe('NETWORK_ERROR'); + expect(error.name).toBe('NetworkError'); + }); + + it('should include network details', () => { + const error = new NetworkError('Request failed', { + statusCode: 404, + endpoint: 'https://api.example.com/test', + method: 'POST', + retryable: true, + retryAfter: 30 + }); + + expect(error.details.statusCode).toBe(404); + expect(error.details.endpoint).toBe('https://api.example.com/test'); + expect(error.details.method).toBe('POST'); + expect(error.details.retryable).toBe(true); + expect(error.details.retryAfter).toBe(30); + }); + }); + + describe('fromAxiosError', () => { + const createAxiosError = (status?: number, data?: unknown, code?: string, headers?: any): AxiosError => { + const error = new Error('Request failed') as AxiosError; + error.isAxiosError = true; + error.code = code; + error.config = { + url: 'https://api.contextmesh.io/v1/connectors', + method: 'post', + headers: new AxiosHeaders() + }; + + if (status) { + error.response = { + status, + statusText: 'Error', + headers: headers || {}, + config: error.config, + data + }; + } + + return error; + }; + + it('should handle 400 Bad Request', () => { + const axiosError = createAxiosError(400, { detail: 'Invalid manifest' }); + const error = NetworkError.fromAxiosError(axiosError); + + expect(error.message).toContain('Bad request'); + expect(error.message).toContain('Invalid manifest'); + expect(error.details.statusCode).toBe(400); + expect(error.details.suggestion).toContain('Check your manifest format'); + }); + + it('should handle 401 Unauthorized', () => { + const axiosError = createAxiosError(401); + const error = NetworkError.fromAxiosError(axiosError); + + expect(error.message).toContain('Authentication failed'); + expect(error.details.statusCode).toBe(401); + expect(error.details.suggestion).toContain('CONTEXTMESH_TOKEN'); + }); + + it('should handle 403 Forbidden', () => { + const axiosError = createAxiosError(403); + const error = NetworkError.fromAxiosError(axiosError); + + expect(error.message).toContain('Permission denied'); + expect(error.details.statusCode).toBe(403); + expect(error.details.suggestion).toContain('necessary permissions'); + }); + + it('should handle 404 Not Found', () => { + const axiosError = createAxiosError(404); + const error = NetworkError.fromAxiosError(axiosError); + + expect(error.message).toContain('Resource not found'); + expect(error.details.statusCode).toBe(404); + expect(error.details.suggestion).toContain('registry URL'); + }); + + it('should handle 409 Conflict', () => { + const axiosError = createAxiosError(409); + const error = NetworkError.fromAxiosError(axiosError); + + expect(error.message).toContain('Conflict'); + expect(error.details.statusCode).toBe(409); + expect(error.details.suggestion).toContain('version'); + }); + + it('should handle 413 Payload Too Large', () => { + const axiosError = createAxiosError(413); + const error = NetworkError.fromAxiosError(axiosError); + + expect(error.message).toContain('Payload too large'); + expect(error.details.statusCode).toBe(413); + expect(error.details.suggestion).toContain('Reduce the size'); + }); + + it('should handle 422 Unprocessable Entity', () => { + const axiosError = createAxiosError(422, { detail: 'Validation error' }); + const error = NetworkError.fromAxiosError(axiosError); + + expect(error.message).toContain('Validation error'); + expect(error.details.statusCode).toBe(422); + }); + + it('should handle 429 Rate Limit', () => { + const axiosError = createAxiosError(429, null, undefined, { 'retry-after': '120' }); + const error = NetworkError.fromAxiosError(axiosError); + + expect(error.message).toContain('Rate limit exceeded'); + expect(error.details.statusCode).toBe(429); + expect(error.details.retryable).toBe(true); + expect(error.details.retryAfter).toBe(120); + }); + + it('should handle 5xx server errors', () => { + const axiosError = createAxiosError(503); + const error = NetworkError.fromAxiosError(axiosError); + + expect(error.message).toContain('Server error'); + expect(error.details.statusCode).toBe(503); + expect(error.details.retryable).toBe(true); + expect(error.details.suggestion).toContain('Try again'); + }); + + it('should handle ECONNREFUSED', () => { + const axiosError = createAxiosError(undefined, undefined, 'ECONNREFUSED'); + const error = NetworkError.fromAxiosError(axiosError); + + expect(error.message).toContain('Connection refused'); + expect(error.details.retryable).toBe(true); + }); + + it('should handle ENOTFOUND', () => { + const axiosError = createAxiosError(undefined, undefined, 'ENOTFOUND'); + const error = NetworkError.fromAxiosError(axiosError); + + expect(error.message).toContain('Server not found'); + expect(error.details.suggestion).toContain('registry URL'); + }); + + it('should handle ETIMEDOUT', () => { + const axiosError = createAxiosError(undefined, undefined, 'ETIMEDOUT'); + const error = NetworkError.fromAxiosError(axiosError); + + expect(error.message).toContain('Request timeout'); + expect(error.details.retryable).toBe(true); + }); + + it('should handle ECONNRESET', () => { + const axiosError = createAxiosError(undefined, undefined, 'ECONNRESET'); + const error = NetworkError.fromAxiosError(axiosError); + + expect(error.message).toContain('Connection reset'); + expect(error.details.retryable).toBe(true); + }); + + it('should include response data details', () => { + const axiosError = createAxiosError(400, { + detail: 'Custom error', + message: 'Alternative message' + }); + const error = NetworkError.fromAxiosError(axiosError); + + expect(error.message).toContain('Custom error'); + expect(error.details.responseData).toEqual({ + detail: 'Custom error', + message: 'Alternative message' + }); + }); + + it('should preserve endpoint information', () => { + const axiosError = createAxiosError(500); + const error = NetworkError.fromAxiosError(axiosError, 'https://custom.api.com'); + + expect(error.details.endpoint).toBe('https://custom.api.com'); + expect(error.details.method).toBe('POST'); + }); + }); + + describe('format', () => { + it('should format with HTTP status code', () => { + const error = new NetworkError('Request failed', { + statusCode: 404, + endpoint: 'https://api.example.com/test', + method: 'GET' + }); + + const formatted = error.format(); + + expect(formatted).toContain('HTTP 404: Request failed'); + expect(formatted).toContain('Endpoint: GET https://api.example.com/test'); + }); + + it('should show retry information', () => { + const error = new NetworkError('Temporary failure', { + retryable: true, + retryAfter: 60 + }); + + const formatted = error.format(); + + expect(formatted).toContain('⟳ This error may be temporary'); + expect(formatted).toContain('Wait 60 seconds before retrying'); + }); + + it('should show retry without specific delay', () => { + const error = new NetworkError('Temporary failure', { + retryable: true + }); + + const formatted = error.format(); + + expect(formatted).toContain('⟳ This error may be temporary'); + expect(formatted).not.toContain('Wait'); + }); + }); + + describe('isRetryable', () => { + it('should return true for retryable errors', () => { + const error = new NetworkError('Temporary', { retryable: true }); + expect(error.isRetryable()).toBe(true); + }); + + it('should return false for non-retryable errors', () => { + const error = new NetworkError('Permanent', { retryable: false }); + expect(error.isRetryable()).toBe(false); + }); + + it('should return false by default', () => { + const error = new NetworkError('Unknown'); + expect(error.isRetryable()).toBe(false); + }); + }); + + describe('getRetryDelay', () => { + it('should return specified retry delay', () => { + const error = new NetworkError('Rate limited', { retryAfter: 30 }); + expect(error.getRetryDelay()).toBe(30); + }); + + it('should return default delay if not specified', () => { + const error = new NetworkError('Temporary'); + expect(error.getRetryDelay()).toBe(5); + }); + }); +}); \ No newline at end of file diff --git a/src/__tests__/errors/validation.test.ts b/src/__tests__/errors/validation.test.ts new file mode 100644 index 0000000..f06012d --- /dev/null +++ b/src/__tests__/errors/validation.test.ts @@ -0,0 +1,195 @@ +import { ValidationError } from '../../errors/validation'; + +describe('ValidationError', () => { + describe('constructor', () => { + it('should create validation error', () => { + const error = new ValidationError('Validation failed'); + + expect(error.message).toBe('Validation failed'); + expect(error.code).toBe('VALIDATION_ERROR'); + expect(error.name).toBe('ValidationError'); + }); + + it('should include validation details', () => { + const error = new ValidationError('Invalid field', { + field: 'connector.id', + line: 5, + column: 10, + suggestion: 'Use lowercase only' + }); + + expect(error.details.field).toBe('connector.id'); + expect(error.details.line).toBe(5); + expect(error.details.column).toBe(10); + expect(error.details.suggestion).toBe('Use lowercase only'); + }); + }); + + describe('fromAjvErrors', () => { + const mockRawContent = `{ + "schema": "https://mcp.dev/schema/1.0", + "id": "Test-Connector", + "tools": [] +}`; + + it('should handle required property errors', () => { + const ajvErrors = [{ + keyword: 'required', + instancePath: '', + schemaPath: '#/required', + params: { missingProperty: '_contextmesh' }, + message: 'must have required property _contextmesh' + }]; + + const error = ValidationError.fromAjvErrors(ajvErrors, mockRawContent); + + expect(error.message).toContain('Missing required property: _contextmesh'); + expect(error.details.field).toBe('root'); + expect(error.details.suggestion).toContain('Add "_contextmesh"'); + }); + + it('should handle pattern errors', () => { + const ajvErrors = [{ + keyword: 'pattern', + instancePath: '/id', + schemaPath: '#/properties/id/pattern', + params: { pattern: '^[a-z0-9-]+$' }, + message: 'must match pattern "^[a-z0-9-]+$"', + data: 'Test-Connector' + }]; + + const error = ValidationError.fromAjvErrors(ajvErrors, mockRawContent); + + expect(error.message).toContain('Invalid format'); + expect(error.details.field).toBe('id'); + expect(error.details.line).toBe(3); // Line where "id" appears + }); + + it('should handle enum errors', () => { + const ajvErrors = [{ + keyword: 'enum', + instancePath: '/_contextmesh/language', + schemaPath: '#/properties/_contextmesh/properties/language/enum', + params: { allowedValues: ['typescript', 'python', 'rust', 'go', 'java'] }, + message: 'must be equal to one of the allowed values' + }]; + + const error = ValidationError.fromAjvErrors(ajvErrors); + + expect(error.message).toContain('Invalid value'); + expect(error.message).toContain('Allowed values: typescript, python, rust, go, java'); + }); + + it('should handle format errors', () => { + const ajvErrors = [{ + keyword: 'format', + instancePath: '/_contextmesh/author/email', + schemaPath: '#/properties/_contextmesh/properties/author/properties/email/format', + params: { format: 'email' }, + message: 'must match format "email"' + }]; + + const error = ValidationError.fromAjvErrors(ajvErrors); + + expect(error.message).toContain('Invalid email format'); + expect(error.details.suggestion).toContain('valid email address'); + }); + + it('should handle multiple errors', () => { + const ajvErrors = [ + { + keyword: 'required', + instancePath: '', + params: { missingProperty: 'tools' }, + message: 'must have required property tools' + }, + { + keyword: 'pattern', + instancePath: '/id', + params: { pattern: '^[a-z0-9-]+$' }, + message: 'must match pattern' + } + ]; + + const error = ValidationError.fromAjvErrors(ajvErrors); + + expect(error.message).toContain('Missing required property: tools'); + expect(error.details.validationErrors).toHaveLength(2); + }); + + it('should find line numbers in raw content', () => { + const ajvErrors = [{ + keyword: 'pattern', + instancePath: '/id', + schemaPath: '#/properties/id/pattern', + params: { pattern: '^[a-z0-9-]+$' }, + message: 'must match pattern' + }]; + + const error = ValidationError.fromAjvErrors(ajvErrors, mockRawContent); + + expect(error.details.line).toBe(3); + expect(error.details.column).toBe(3); // Position of "id" in line + }); + + it('should handle array index paths', () => { + const ajvErrors = [{ + keyword: 'required', + instancePath: '/tools/0', + params: { missingProperty: 'name' }, + message: 'must have required property name' + }]; + + const error = ValidationError.fromAjvErrors(ajvErrors); + + expect(error.details.field).toBe('tools/0'); + }); + + it('should handle type errors', () => { + const ajvErrors = [{ + keyword: 'type', + instancePath: '/tools', + schemaPath: '#/properties/tools/type', + params: { type: 'array' }, + message: 'must be array', + data: 'not-an-array' + }]; + + const error = ValidationError.fromAjvErrors(ajvErrors); + + expect(error.message).toContain('Expected array but got string'); + }); + }); + + describe('format', () => { + it('should format error with additional validation errors', () => { + const error = new ValidationError('Main error', { + validationErrors: [ + { path: 'field1', message: 'Error 1' }, + { path: 'field2', message: 'Error 2' }, + { path: 'field3', message: 'Error 3' } + ] + }); + + const formatted = error.format(); + + expect(formatted).toContain('Main error'); + expect(formatted).toContain('Additional validation errors:'); + expect(formatted).toContain('2. field2: Error 2'); + expect(formatted).toContain('3. field3: Error 3'); + }); + + it('should not show additional errors section if only one error', () => { + const error = new ValidationError('Single error', { + validationErrors: [ + { path: 'field1', message: 'Error 1' } + ] + }); + + const formatted = error.format(); + + expect(formatted).toContain('Single error'); + expect(formatted).not.toContain('Additional validation errors:'); + }); + }); +}); \ No newline at end of file diff --git a/src/__tests__/utils/manifest.test.ts b/src/__tests__/utils/manifest.test.ts new file mode 100644 index 0000000..041470b --- /dev/null +++ b/src/__tests__/utils/manifest.test.ts @@ -0,0 +1,207 @@ +import { loadManifest, createManifestIfMissing } from '../../utils/manifest'; +import { FileSystemError } from '../../errors'; +import { existsSync, writeFileSync, readFileSync } from 'fs'; +import { join } from 'path'; + +// Mock fs functions +jest.mock('fs', () => ({ + existsSync: jest.fn(), + writeFileSync: jest.fn(), + readFileSync: jest.fn() +})); + +const mockExistsSync = existsSync as jest.MockedFunction; +const mockWriteFileSync = writeFileSync as jest.MockedFunction; +const mockReadFileSync = readFileSync as jest.MockedFunction; + +describe('loadManifest', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should load valid manifest successfully', () => { + const validManifest = { + schema: 'https://mcp.dev/schema/1.0', + id: 'test-connector', + tools: [], + _contextmesh: { + version: '1.0.0', + tags: ['test'], + language: 'typescript', + repo: 'https://github.com/test/repo' + } + }; + + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(JSON.stringify(validManifest)); + + const result = loadManifest('/test/connector.mcp.json'); + + expect(result).toEqual(validManifest); + expect(mockExistsSync).toHaveBeenCalledWith('/test/connector.mcp.json'); + expect(mockReadFileSync).toHaveBeenCalledWith('/test/connector.mcp.json', 'utf-8'); + }); + + it('should throw FileSystemError.fileNotFound when manifest does not exist', () => { + mockExistsSync.mockReturnValue(false); + + expect(() => { + loadManifest('/test/missing.json'); + }).toThrow(FileSystemError); + + const error = (() => { + try { + loadManifest('/test/missing.json'); + return null; + } catch (e) { + return e as FileSystemError; + } + })(); + + expect(error?.code).toBe('FILESYSTEM_ERROR'); + expect(error?.message).toContain('File not found: /test/missing.json'); + }); + + it('should throw FileSystemError for invalid JSON syntax', () => { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue('{ invalid json syntax }'); + + expect(() => { + loadManifest('/test/invalid.json'); + }).toThrow(FileSystemError); + + const error = (() => { + try { + loadManifest('/test/invalid.json'); + return null; + } catch (e) { + return e as FileSystemError; + } + })(); + + expect(error?.code).toBe('FILESYSTEM_ERROR'); + expect(error?.message).toContain('Invalid JSON in manifest file'); + expect(error?.details.path).toBe('/test/invalid.json'); + expect(error?.details.operation).toBe('read'); + expect(error?.details.suggestion).toContain('Check for syntax errors'); + }); + + it('should wrap Node.js file system errors', () => { + mockExistsSync.mockReturnValue(true); + + const fsError = new Error('Permission denied') as Error & { code?: string; path?: string }; + fsError.code = 'EACCES'; + fsError.path = '/test/protected.json'; + + mockReadFileSync.mockImplementation(() => { + throw fsError; + }); + + expect(() => { + loadManifest('/test/protected.json'); + }).toThrow(FileSystemError); + + const error = (() => { + try { + loadManifest('/test/protected.json'); + return null; + } catch (e) { + return e as FileSystemError; + } + })(); + + expect(error?.code).toBe('FILESYSTEM_ERROR'); + expect(error?.details.errorCode).toBe('EACCES'); + expect(error?.details.operation).toBe('read'); + }); + + it('should re-throw FileSystemError unchanged', () => { + mockExistsSync.mockReturnValue(true); + + const originalError = new FileSystemError('Custom filesystem error'); + mockReadFileSync.mockImplementation(() => { + throw originalError; + }); + + expect(() => { + loadManifest('/test/test.json'); + }).toThrow(originalError); + }); + + it('should pass through unknown errors', () => { + mockExistsSync.mockReturnValue(true); + + const unknownError = new Error('Unknown error type'); + mockReadFileSync.mockImplementation(() => { + throw unknownError; + }); + + expect(() => { + loadManifest('/test/test.json'); + }).toThrow(unknownError); + }); +}); + +describe('createManifestIfMissing', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Mock console.log to avoid output during tests + jest.spyOn(console, 'log').mockImplementation(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should return existing manifest path if file exists', async () => { + mockExistsSync.mockReturnValue(true); + + const result = await createManifestIfMissing('/test/connector'); + + expect(result).toBe(join('/test/connector', 'connector.mcp.json')); + expect(mockExistsSync).toHaveBeenCalledWith(join('/test/connector', 'connector.mcp.json')); + expect(mockWriteFileSync).not.toHaveBeenCalled(); + }); + + it('should create default manifest if file does not exist', async () => { + mockExistsSync.mockReturnValue(false); + + const result = await createManifestIfMissing('/test/my-awesome-connector'); + + expect(result).toBe(join('/test/my-awesome-connector', 'connector.mcp.json')); + expect(mockWriteFileSync).toHaveBeenCalled(); + + const [path, content] = mockWriteFileSync.mock.calls[0]; + expect(path).toBe(join('/test/my-awesome-connector', 'connector.mcp.json')); + + const manifest = JSON.parse(content as string); + expect(manifest.id).toBe('my-awesome-connector'); + expect(manifest.name).toBe('My Awesome Connector'); + expect(manifest.schema).toBe('https://mcp.dev/schema/1.0'); + expect(manifest._contextmesh.version).toBe('0.1.0'); + expect(manifest._contextmesh.language).toBe('typescript'); + expect(manifest.tools).toHaveLength(1); + }); + + it('should sanitize directory name for connector ID', async () => { + mockExistsSync.mockReturnValue(false); + + await createManifestIfMissing('/test/My_Invalid@Connector#Name!'); + + const [, content] = mockWriteFileSync.mock.calls[0]; + const manifest = JSON.parse(content as string); + + expect(manifest.id).toBe('my-invalid-connector-name-'); + }); + + it('should create proper connector name from ID', async () => { + mockExistsSync.mockReturnValue(false); + + await createManifestIfMissing('/test/github-api-connector'); + + const [, content] = mockWriteFileSync.mock.calls[0]; + const manifest = JSON.parse(content as string); + + expect(manifest.name).toBe('Github Api Connector'); + }); +}); \ No newline at end of file diff --git a/src/__tests__/utils/retry.test.ts b/src/__tests__/utils/retry.test.ts new file mode 100644 index 0000000..10890d8 --- /dev/null +++ b/src/__tests__/utils/retry.test.ts @@ -0,0 +1,189 @@ +import { withRetry } from '../../utils/retry'; +import { NetworkError } from '../../errors/network'; + +describe('withRetry', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should return result on first success', async () => { + const fn = jest.fn().mockResolvedValue('success'); + + const result = await withRetry(fn); + + expect(result).toBe('success'); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('should retry on retryable network error', async () => { + const networkError = new NetworkError('Temporary failure', { retryable: true }); + const fn = jest.fn() + .mockRejectedValueOnce(networkError) + .mockResolvedValue('success'); + + const promise = withRetry(fn); + + // First attempt fails + await jest.runAllTimersAsync(); + + const result = await promise; + + expect(result).toBe('success'); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('should not retry on non-retryable error', async () => { + const error = new NetworkError('Permanent failure', { retryable: false }); + const fn = jest.fn().mockRejectedValue(error); + + await expect(withRetry(fn)).rejects.toThrow(error); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('should not retry on non-NetworkError', async () => { + const error = new Error('Generic error'); + const fn = jest.fn().mockRejectedValue(error); + + await expect(withRetry(fn)).rejects.toThrow(error); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('should respect maxAttempts', async () => { + jest.useRealTimers(); // Use real timers for this test + + const error = new NetworkError('Always fails', { retryable: true }); + const fn = jest.fn().mockRejectedValue(error); + + await expect(withRetry(fn, { maxAttempts: 3, baseDelay: 1 })).rejects.toBe(error); + expect(fn).toHaveBeenCalledTimes(3); + + jest.useFakeTimers(); // Restore fake timers + }); + + it('should use exponential backoff', async () => { + const error = new NetworkError('Retry me', { retryable: true }); + const fn = jest.fn() + .mockRejectedValueOnce(error) + .mockRejectedValueOnce(error) + .mockResolvedValue('success'); + + const onRetry = jest.fn(); + + const promise = withRetry(fn, { + baseDelay: 1000, + factor: 2, + onRetry + }); + + // Process retries + await jest.runAllTimersAsync(); + + await promise; + + // Check delays: 1000ms for first retry, 2000ms for second retry + expect(onRetry).toHaveBeenCalledTimes(2); + expect(onRetry).toHaveBeenNthCalledWith(1, 1, error, 1000); + expect(onRetry).toHaveBeenNthCalledWith(2, 2, error, 2000); + }); + + it('should respect maxDelay', async () => { + const error = new NetworkError('Retry me', { retryable: true }); + const fn = jest.fn() + .mockRejectedValueOnce(error) + .mockRejectedValueOnce(error) + .mockResolvedValue('success'); + + const onRetry = jest.fn(); + + const promise = withRetry(fn, { + baseDelay: 10000, + maxDelay: 5000, + factor: 10, + onRetry + }); + + await jest.runAllTimersAsync(); + await promise; + + // Both retries should be capped at maxDelay + expect(onRetry).toHaveBeenNthCalledWith(1, 1, error, 5000); + expect(onRetry).toHaveBeenNthCalledWith(2, 2, error, 5000); + }); + + it('should use retry-after header from NetworkError', async () => { + const error = new NetworkError('Rate limited', { + retryable: true, + retryAfter: 10 // 10 seconds + }); + const fn = jest.fn() + .mockRejectedValueOnce(error) + .mockResolvedValue('success'); + + const onRetry = jest.fn(); + + const promise = withRetry(fn, { onRetry }); + + await jest.runAllTimersAsync(); + await promise; + + // Should use 10000ms (10 seconds * 1000) + expect(onRetry).toHaveBeenCalledWith(1, error, 10000); + }); + + it('should call onRetry callback', async () => { + const error = new NetworkError('Retry me', { retryable: true }); + const fn = jest.fn() + .mockRejectedValueOnce(error) + .mockResolvedValue('success'); + + const onRetry = jest.fn(); + + const promise = withRetry(fn, { onRetry }); + + await jest.runAllTimersAsync(); + await promise; + + expect(onRetry).toHaveBeenCalledWith(1, error, 1000); + }); + + it('should eventually succeed after multiple retries', async () => { + const error = new NetworkError('Flaky service', { retryable: true }); + const fn = jest.fn() + .mockRejectedValueOnce(error) + .mockRejectedValueOnce(error) + .mockResolvedValue('finally!'); + + const promise = withRetry(fn, { maxAttempts: 3 }); + + await jest.runAllTimersAsync(); + + const result = await promise; + expect(result).toBe('finally!'); + expect(fn).toHaveBeenCalledTimes(3); + }); + + it('should use custom options', async () => { + const error = new NetworkError('Custom retry', { retryable: true }); + const fn = jest.fn() + .mockRejectedValueOnce(error) + .mockResolvedValue('success'); + + const onRetry = jest.fn(); + + const promise = withRetry(fn, { + maxAttempts: 5, + baseDelay: 500, + factor: 3, + onRetry + }); + + await jest.runAllTimersAsync(); + await promise; + + expect(onRetry).toHaveBeenCalledWith(1, error, 500); + }); +}); \ No newline at end of file diff --git a/src/__tests__/validator.test.ts b/src/__tests__/validator.test.ts index 8e47f41..13d8ac5 100644 --- a/src/__tests__/validator.test.ts +++ b/src/__tests__/validator.test.ts @@ -1,4 +1,5 @@ import { validateManifest } from '../utils/validator'; +import { ValidationError } from '../errors'; import { writeFileSync, mkdirSync, rmSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; @@ -109,8 +110,15 @@ describe('Enhanced Manifest Validation', () => { const manifestPath = join(testDir, 'connector.mcp.json'); writeFileSync(manifestPath, JSON.stringify(manifest)); - await expect(validateManifest(manifestPath)).rejects.toThrow('Manifest validation failed'); - expect(console.error).toHaveBeenCalledWith(expect.stringContaining('āŒ Manifest validation failed')); + await expect(validateManifest(manifestPath)).rejects.toThrow(ValidationError); + + try { + await validateManifest(manifestPath); + } catch (error) { + expect(error).toBeInstanceOf(ValidationError); + expect((error as ValidationError).message).toContain('Missing required property: _contextmesh'); + expect((error as ValidationError).details.field).toBe('root'); + } }); it('should fail on invalid connector ID with suggestion', async () => { @@ -134,8 +142,16 @@ describe('Enhanced Manifest Validation', () => { const manifestPath = join(testDir, 'connector.mcp.json'); writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); - await expect(validateManifest(manifestPath)).rejects.toThrow('Manifest validation failed'); - expect(console.error).toHaveBeenCalledWith(expect.stringContaining('Invalid format')); + await expect(validateManifest(manifestPath)).rejects.toThrow(ValidationError); + + try { + await validateManifest(manifestPath); + } catch (error) { + expect(error).toBeInstanceOf(ValidationError); + expect((error as ValidationError).message).toContain('Invalid format'); + expect((error as ValidationError).details.field).toBe('id'); + expect((error as ValidationError).details.line).toBe(3); // Line where "id" appears + } }); it('should fail on invalid version format', async () => { @@ -159,7 +175,7 @@ describe('Enhanced Manifest Validation', () => { const manifestPath = join(testDir, 'connector.mcp.json'); writeFileSync(manifestPath, JSON.stringify(manifest)); - await expect(validateManifest(manifestPath)).rejects.toThrow('Manifest validation failed'); + await expect(validateManifest(manifestPath)).rejects.toThrow(ValidationError); }); it('should fail on invalid email format', async () => { @@ -187,7 +203,7 @@ describe('Enhanced Manifest Validation', () => { const manifestPath = join(testDir, 'connector.mcp.json'); writeFileSync(manifestPath, JSON.stringify(manifest)); - await expect(validateManifest(manifestPath)).rejects.toThrow('Manifest validation failed'); + await expect(validateManifest(manifestPath)).rejects.toThrow(ValidationError); }); it('should fail on invalid URL format', async () => { @@ -211,7 +227,7 @@ describe('Enhanced Manifest Validation', () => { const manifestPath = join(testDir, 'connector.mcp.json'); writeFileSync(manifestPath, JSON.stringify(manifest)); - await expect(validateManifest(manifestPath)).rejects.toThrow('Manifest validation failed'); + await expect(validateManifest(manifestPath)).rejects.toThrow(ValidationError); }); }); @@ -232,7 +248,14 @@ describe('Enhanced Manifest Validation', () => { const manifestPath = join(testDir, 'connector.mcp.json'); writeFileSync(manifestPath, JSON.stringify(manifest)); - await expect(validateManifest(manifestPath)).rejects.toThrow('Connector must define at least one tool'); + await expect(validateManifest(manifestPath)).rejects.toThrow(ValidationError); + + try { + await validateManifest(manifestPath); + } catch (error) { + expect(error).toBeInstanceOf(ValidationError); + expect((error as ValidationError).message).toContain('Connector must define at least one tool'); + } }); it('should fail on duplicate tool names', async () => { @@ -260,7 +283,14 @@ describe('Enhanced Manifest Validation', () => { const manifestPath = join(testDir, 'connector.mcp.json'); writeFileSync(manifestPath, JSON.stringify(manifest)); - await expect(validateManifest(manifestPath)).rejects.toThrow('Duplicate tool names found: duplicate_tool'); + await expect(validateManifest(manifestPath)).rejects.toThrow(ValidationError); + + try { + await validateManifest(manifestPath); + } catch (error) { + expect(error).toBeInstanceOf(ValidationError); + expect((error as ValidationError).message).toContain('Duplicate tool names found: duplicate_tool'); + } }); it('should warn on non-standard repository URL', async () => { @@ -307,11 +337,16 @@ describe('Enhanced Manifest Validation', () => { const manifestPath = join(testDir, 'connector.mcp.json'); writeFileSync(manifestPath, invalidManifest); - await expect(validateManifest(manifestPath)).rejects.toThrow('Manifest validation failed'); + await expect(validateManifest(manifestPath)).rejects.toThrow(ValidationError); - // Check that error reporting includes helpful information - expect(console.error).toHaveBeenCalledWith(expect.stringContaining('āŒ Manifest validation failed')); - expect(console.error).toHaveBeenCalledWith(expect.stringContaining('Line')); + try { + await validateManifest(manifestPath); + } catch (error) { + expect(error).toBeInstanceOf(ValidationError); + const validationError = error as ValidationError; + expect(validationError.details.line).toBeDefined(); + expect(validationError.details.field).toBeDefined(); + } }); }); }); \ No newline at end of file diff --git a/src/commands/publish.ts b/src/commands/publish.ts index e102f16..9da9e0f 100644 --- a/src/commands/publish.ts +++ b/src/commands/publish.ts @@ -5,6 +5,7 @@ import ora from 'ora'; import { publishConnector } from '../utils/publisher'; import { validateManifest } from '../utils/validator'; import { createManifestIfMissing } from '../utils/manifest'; +import { handleError } from '../errors'; export const publishCommand = new Command('publish') .description('Publish a connector to the ContextMesh registry') @@ -12,6 +13,7 @@ export const publishCommand = new Command('publish') .option('-r, --registry ', 'Registry URL', process.env.CONTEXTMESH_REGISTRY || 'https://api.contextmesh.io') .option('-t, --token ', 'Authentication token', process.env.CONTEXTMESH_TOKEN) .option('--dry-run', 'Perform a dry run without uploading') + .option('-v, --verbose', 'Show detailed error information') .action(async (directory: string, options) => { const spinner = ora(); @@ -59,8 +61,7 @@ export const publishCommand = new Command('publish') console.log(chalk.white(` contextmesh install ${result.id}@${result.version}`)); } catch (error) { - spinner.fail('Publication failed'); - console.error(chalk.red(`\nāŒ Error: ${error instanceof Error ? error.message : 'Unknown error'}`)); - process.exit(1); + spinner.fail(''); + handleError(error, options.verbose); } }); \ No newline at end of file diff --git a/src/errors/auth.ts b/src/errors/auth.ts new file mode 100644 index 0000000..cfd3f59 --- /dev/null +++ b/src/errors/auth.ts @@ -0,0 +1,78 @@ +import { ContextMeshError, ErrorDetails } from './base'; + +export interface AuthErrorDetails extends ErrorDetails { + tokenSource?: 'environment' | 'flag' | 'file'; + tokenPresent?: boolean; +} + +export class AuthenticationError extends ContextMeshError { + constructor(message: string, details: Partial = {}) { + super(message, 'AUTH_ERROR', details); + } + + static missingToken(): AuthenticationError { + return new AuthenticationError( + 'No authentication token provided', + { + tokenPresent: false, + suggestion: 'Set CONTEXTMESH_TOKEN environment variable or use --token flag\n' + + ' Example: export CONTEXTMESH_TOKEN="your-token-here"\n' + + ' Or: contextmesh publish --token "your-token-here"' + } + ); + } + + static invalidToken(source: 'environment' | 'flag' | 'file' = 'environment'): AuthenticationError { + return new AuthenticationError( + 'Invalid authentication token', + { + tokenSource: source, + tokenPresent: true, + suggestion: 'Your token may be expired or invalid. To get a new token:\n' + + ' 1. Visit https://app.contextmesh.io/settings/tokens\n' + + ' 2. Generate a new API token\n' + + ' 3. Update your CONTEXTMESH_TOKEN environment variable' + } + ); + } + + static expiredToken(): AuthenticationError { + return new AuthenticationError( + 'Authentication token has expired', + { + tokenPresent: true, + suggestion: 'Your token has expired. Generate a new one:\n' + + ' 1. Visit https://app.contextmesh.io/settings/tokens\n' + + ' 2. Generate a new API token\n' + + ' 3. Update your CONTEXTMESH_TOKEN environment variable' + } + ); + } + + static insufficientPermissions(action: string): AuthenticationError { + return new AuthenticationError( + `Insufficient permissions to ${action}`, + { + tokenPresent: true, + suggestion: 'Your token does not have the required permissions.\n' + + ' Ensure your token has "write:connectors" scope for publishing.\n' + + ' Generate a new token with proper permissions at:\n' + + ' https://app.contextmesh.io/settings/tokens' + } + ); + } + + format(verbose: boolean = false): string { + let output = super.format(verbose); + + if (this.details.tokenSource) { + output += `\n Token source: ${this.details.tokenSource}`; + } + + if (this.details.tokenPresent !== undefined) { + output += `\n Token present: ${this.details.tokenPresent ? 'Yes' : 'No'}`; + } + + return output; + } +} \ No newline at end of file diff --git a/src/errors/base.ts b/src/errors/base.ts new file mode 100644 index 0000000..41842ae --- /dev/null +++ b/src/errors/base.ts @@ -0,0 +1,95 @@ +export interface ErrorDetails { + code: string; + field?: string; + line?: number; + column?: number; + suggestion?: string; + originalError?: Error; + [key: string]: unknown; // Allow additional properties +} + +export class ContextMeshError extends Error { + public readonly code: string; + public readonly details: ErrorDetails; + public readonly timestamp: Date; + + constructor(message: string, code: string, details: Partial = {}) { + super(message); + this.name = this.constructor.name; + this.code = code; + this.details = { code, ...details }; + this.timestamp = new Date(); + + // Maintain proper stack trace for where our error was thrown + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor); + } + } + + /** + * Format error for display to users + */ + public format(verbose: boolean = false): string { + let output = this.message; + + if (this.details.field) { + output += `\n Field: ${this.details.field}`; + } + + if (this.details.line) { + output += `\n Location: Line ${this.details.line}`; + if (this.details.column) { + output += `, Column ${this.details.column}`; + } + } + + if (this.details.suggestion) { + output += `\n šŸ’” Suggestion: ${this.details.suggestion}`; + } + + if (verbose && this.stack) { + output += `\n\nStack trace:\n${this.stack}`; + } + + return output; + } + + /** + * Convert to JSON for logging + */ + public toJSON() { + return { + name: this.name, + code: this.code, + message: this.message, + details: this.details, + timestamp: this.timestamp, + stack: this.stack + }; + } +} + +/** + * Check if an error is a ContextMeshError + */ +export function isContextMeshError(error: unknown): error is ContextMeshError { + return error instanceof ContextMeshError; +} + +/** + * Wrap an unknown error in a ContextMeshError + */ +export function wrapError(error: unknown, code: string, message?: string): ContextMeshError { + if (isContextMeshError(error)) { + return error; + } + + const errorMessage = message || (error instanceof Error ? error.message : String(error)); + const details: Partial = {}; + + if (error instanceof Error) { + details.originalError = error; + } + + return new ContextMeshError(errorMessage, code, details); +} \ No newline at end of file diff --git a/src/errors/filesystem.ts b/src/errors/filesystem.ts new file mode 100644 index 0000000..ef2d63c --- /dev/null +++ b/src/errors/filesystem.ts @@ -0,0 +1,142 @@ +import { ContextMeshError, ErrorDetails } from './base'; + +export interface FileSystemErrorDetails extends ErrorDetails { + path?: string; + operation?: 'read' | 'write' | 'delete' | 'create' | 'access'; + errorCode?: string; +} + +export class FileSystemError extends ContextMeshError { + constructor(message: string, details: Partial = {}) { + super(message, 'FILESYSTEM_ERROR', details); + } + + static fromNodeError(error: Error & { code?: string; path?: string }, path?: string, operation?: string): FileSystemError { + let message = 'File system operation failed'; + let suggestion: string | undefined; + + switch (error.code) { + case 'ENOENT': + message = `File or directory not found: ${path || error.path}`; + suggestion = 'Check that the file exists and the path is correct'; + break; + case 'EACCES': + message = `Permission denied: ${path || error.path}`; + suggestion = 'Check file permissions or run with appropriate privileges'; + break; + case 'EISDIR': + message = `Expected a file but found a directory: ${path || error.path}`; + suggestion = 'Provide a path to a file, not a directory'; + break; + case 'ENOTDIR': + message = `Expected a directory but found a file: ${path || error.path}`; + suggestion = 'Provide a path to a directory, not a file'; + break; + case 'ENOSPC': + message = 'No space left on device'; + suggestion = 'Free up disk space and try again'; + break; + case 'EMFILE': + message = 'Too many open files'; + suggestion = 'Close some applications and try again'; + break; + case 'EEXIST': + message = `File already exists: ${path || error.path}`; + suggestion = 'Remove the existing file or choose a different name'; + break; + case 'EROFS': + message = 'Read-only file system'; + suggestion = 'Cannot write to a read-only file system'; + break; + default: + if (error.message) { + message = error.message; + } + } + + return new FileSystemError(message, { + path: path || error.path, + operation: operation as FileSystemErrorDetails['operation'], + errorCode: error.code, + suggestion, + originalError: error + }); + } + + static fileNotFound(path: string): FileSystemError { + return new FileSystemError( + `File not found: ${path}`, + { + path, + operation: 'read', + errorCode: 'ENOENT', + suggestion: 'Make sure the file exists and the path is correct' + } + ); + } + + static directoryNotFound(path: string): FileSystemError { + return new FileSystemError( + `Directory not found: ${path}`, + { + path, + operation: 'access', + errorCode: 'ENOENT', + suggestion: 'Make sure the directory exists or use "." for current directory' + } + ); + } + + static permissionDenied(path: string, operation: string = 'access'): FileSystemError { + return new FileSystemError( + `Permission denied: Cannot ${operation} ${path}`, + { + path, + operation: operation as FileSystemErrorDetails['operation'], + errorCode: 'EACCES', + suggestion: 'Check file permissions or run with appropriate privileges' + } + ); + } + + static manifestNotFound(directory: string): FileSystemError { + return new FileSystemError( + 'No connector.mcp.json found in directory', + { + path: `${directory}/connector.mcp.json`, + operation: 'read', + errorCode: 'ENOENT', + suggestion: 'Run this command from a connector directory or specify the path.\n' + + ' The command will create a basic manifest if none exists.' + } + ); + } + + static cannotCreateZip(reason?: string): FileSystemError { + return new FileSystemError( + `Failed to create connector archive${reason ? `: ${reason}` : ''}`, + { + operation: 'write', + suggestion: 'Ensure you have write permissions and sufficient disk space' + } + ); + } + + format(verbose: boolean = false): string { + let output = super.format(verbose); + + if (this.details.path) { + output += `\n Path: ${this.details.path}`; + } + + if (this.details.operation) { + output += `\n Operation: ${this.details.operation}`; + } + + if (this.details.errorCode) { + output += `\n Error code: ${this.details.errorCode}`; + } + + return output; + } +} \ No newline at end of file diff --git a/src/errors/index.ts b/src/errors/index.ts new file mode 100644 index 0000000..2baeebb --- /dev/null +++ b/src/errors/index.ts @@ -0,0 +1,134 @@ +// Re-export all error classes and utilities +export * from './base'; +export * from './validation'; +export * from './network'; +export * from './auth'; +export * from './filesystem'; + +import { ContextMeshError, isContextMeshError } from './base'; +import { ValidationError } from './validation'; +import { NetworkError } from './network'; +import { AuthenticationError } from './auth'; +import { FileSystemError } from './filesystem'; +import { AxiosError } from 'axios'; +import chalk from 'chalk'; + +/** + * Format any error for display to the user + */ +export function formatError(error: unknown, verbose: boolean = false): string { + if (isContextMeshError(error)) { + return error.format(verbose); + } + + if (error instanceof Error) { + let output = error.message; + if (verbose && error.stack) { + output += `\n\nStack trace:\n${error.stack}`; + } + return output; + } + + return String(error); +} + +/** + * Handle an error and format it appropriately + */ +export function handleError(error: unknown, verbose: boolean = false): void { + let formattedError: string; + let exitCode = 1; + + if (isContextMeshError(error)) { + formattedError = chalk.red(`āŒ ${error.format(verbose)}`); + + // Set appropriate exit codes + switch (error.code) { + case 'AUTH_ERROR': + exitCode = 2; + break; + case 'VALIDATION_ERROR': + exitCode = 3; + break; + case 'NETWORK_ERROR': + exitCode = 4; + break; + case 'FILESYSTEM_ERROR': + exitCode = 5; + break; + } + } else if (error instanceof Error) { + // Try to identify and wrap known error types + const wrappedError = identifyAndWrapError(error); + if (wrappedError) { + formattedError = chalk.red(`āŒ ${wrappedError.format(verbose)}`); + } else { + formattedError = chalk.red(`āŒ Error: ${error.message}`); + if (verbose && error.stack) { + formattedError += chalk.gray(`\n\nStack trace:\n${error.stack}`); + } + } + } else { + formattedError = chalk.red(`āŒ Error: ${String(error)}`); + } + + console.error(formattedError); + process.exit(exitCode); +} + +/** + * Try to identify common errors and wrap them appropriately + */ +function identifyAndWrapError(error: Error): ContextMeshError | null { + // Check for Axios errors + if ('isAxiosError' in error && error.isAxiosError) { + return NetworkError.fromAxiosError(error as AxiosError); + } + + // Check for Node.js file system errors + if ('code' in error && typeof error.code === 'string') { + const fsErrorCodes = ['ENOENT', 'EACCES', 'EISDIR', 'ENOTDIR', 'ENOSPC', 'EMFILE', 'EEXIST', 'EROFS']; + if (fsErrorCodes.includes(error.code)) { + return FileSystemError.fromNodeError(error as Error & { code?: string; path?: string }); + } + } + + // Check for validation-related errors + if (error.message.toLowerCase().includes('validation') || error.message.toLowerCase().includes('invalid')) { + return new ValidationError(error.message, { originalError: error }); + } + + // Check for auth-related errors + if (error.message.toLowerCase().includes('auth') || error.message.toLowerCase().includes('token')) { + return new AuthenticationError(error.message, { originalError: error }); + } + + return null; +} + +/** + * Create a user-friendly error message with suggestions + */ +export function createErrorMessage( + title: string, + details: string[], + suggestions?: string[] +): string { + let message = chalk.red(`āŒ ${title}`); + + if (details.length > 0) { + message += '\n\n' + chalk.white('Details:'); + details.forEach(detail => { + message += '\n • ' + detail; + }); + } + + if (suggestions && suggestions.length > 0) { + message += '\n\n' + chalk.yellow('šŸ’” Suggestions:'); + suggestions.forEach(suggestion => { + message += '\n • ' + suggestion; + }); + } + + return message; +} \ No newline at end of file diff --git a/src/errors/network.ts b/src/errors/network.ts new file mode 100644 index 0000000..64864ea --- /dev/null +++ b/src/errors/network.ts @@ -0,0 +1,150 @@ +import { ContextMeshError, ErrorDetails } from './base'; +import { AxiosError } from 'axios'; + +export interface NetworkErrorDetails extends ErrorDetails { + statusCode?: number; + endpoint?: string; + method?: string; + responseData?: unknown; + retryable?: boolean; + retryAfter?: number; +} + +export class NetworkError extends ContextMeshError { + constructor(message: string, details: Partial = {}) { + super(message, 'NETWORK_ERROR', details); + } + + static fromAxiosError(error: AxiosError, endpoint?: string): NetworkError { + const statusCode = error.response?.status; + const responseData = error.response?.data; + let message = 'Network request failed'; + let suggestion: string | undefined; + let retryable = false; + let retryAfter: number | undefined; + + // Handle specific status codes + if (statusCode) { + switch (statusCode) { + case 400: + message = 'Bad request: Invalid data sent to server'; + suggestion = 'Check your manifest format and try again'; + break; + case 401: + message = 'Authentication failed: Invalid or expired token'; + suggestion = 'Check your CONTEXTMESH_TOKEN or use --token flag with a valid token'; + break; + case 403: + message = 'Permission denied: You do not have access to this resource'; + suggestion = 'Ensure you have the necessary permissions for this operation'; + break; + case 404: + message = 'Resource not found'; + suggestion = 'Check the registry URL and connector ID'; + break; + case 409: + message = 'Conflict: Resource already exists'; + suggestion = 'This version may already be published. Try incrementing the version number'; + break; + case 413: + message = 'Payload too large: Connector package exceeds size limit'; + suggestion = 'Reduce the size of your connector package (check for large files)'; + break; + case 422: + message = 'Validation error: Server rejected the request'; + if (responseData && typeof responseData === 'object' && 'detail' in responseData) { + message += `: ${responseData.detail}`; + } + break; + case 429: + message = 'Rate limit exceeded'; + suggestion = 'Wait a few minutes before trying again'; + retryable = true; + retryAfter = error.response?.headers['retry-after'] + ? parseInt(error.response.headers['retry-after']) + : 60; + break; + case 500: + case 502: + case 503: + case 504: + message = 'Server error: The registry is experiencing issues'; + suggestion = 'Try again in a few minutes. If the problem persists, check https://status.contextmesh.io'; + retryable = true; + break; + default: + message = `HTTP ${statusCode} error`; + } + } else if (error.code === 'ECONNREFUSED') { + message = 'Connection refused: Cannot reach the registry server'; + suggestion = 'Check your internet connection and the registry URL'; + retryable = true; + } else if (error.code === 'ENOTFOUND') { + message = 'Server not found: Invalid registry URL'; + suggestion = 'Check the registry URL (default: https://api.contextmesh.io)'; + } else if (error.code === 'ETIMEDOUT' || error.code === 'ECONNABORTED') { + message = 'Request timeout: Server took too long to respond'; + suggestion = 'Check your internet connection and try again'; + retryable = true; + } else if (error.code === 'ECONNRESET') { + message = 'Connection reset: Server closed the connection unexpectedly'; + suggestion = 'Try again. This is usually a temporary issue'; + retryable = true; + } + + // Add response details if available + if (responseData && typeof responseData === 'object') { + if ('detail' in responseData && typeof responseData.detail === 'string' && !message.includes(responseData.detail)) { + message += `: ${responseData.detail}`; + } else if ('message' in responseData && typeof responseData.message === 'string') { + message += `: ${responseData.message}`; + } + } + + return new NetworkError(message, { + statusCode, + endpoint: endpoint || error.config?.url, + method: error.config?.method?.toUpperCase(), + responseData, + suggestion, + retryable, + retryAfter, + originalError: error + }); + } + + format(verbose: boolean = false): string { + let output = super.format(verbose); + + if (this.details.statusCode) { + output = `HTTP ${this.details.statusCode}: ${output}`; + } + + if (this.details.endpoint) { + output += `\n Endpoint: ${this.details.method || 'GET'} ${this.details.endpoint}`; + } + + if (this.details.retryable) { + output += '\n ⟳ This error may be temporary. You can try again.'; + if (this.details.retryAfter) { + output += ` Wait ${this.details.retryAfter} seconds before retrying.`; + } + } + + return output; + } + + /** + * Check if this error is potentially retryable + */ + isRetryable(): boolean { + return (this.details.retryable as boolean) || false; + } + + /** + * Get suggested retry delay in seconds + */ + getRetryDelay(): number { + return (this.details.retryAfter as number) || 5; + } +} \ No newline at end of file diff --git a/src/errors/validation.ts b/src/errors/validation.ts new file mode 100644 index 0000000..3eb6c27 --- /dev/null +++ b/src/errors/validation.ts @@ -0,0 +1,175 @@ +import { ContextMeshError, ErrorDetails } from './base'; + +export interface ValidationErrorDetails extends ErrorDetails { + validationErrors?: Array<{ + path: string; + message: string; + keyword?: string; + params?: Record; + }>; +} + +export class ValidationError extends ContextMeshError { + constructor(message: string, details: Partial = {}) { + super(message, 'VALIDATION_ERROR', details); + } + + static fromAjvErrors(ajvErrors: unknown[], rawContent?: string): ValidationError { + const errors = ajvErrors.map((error: any) => { + const path = error.instancePath || '/'; + let message = error.message || 'Unknown validation error'; + let suggestion: string | undefined; + + // Enhance error messages + const params = error.params as Record | undefined; + switch (error.keyword) { + case 'required': + const missingProp = params?.missingProperty; + message = `Missing required property: ${missingProp}`; + suggestion = getRequiredPropertySuggestion(missingProp as string); + break; + case 'pattern': + message = `Invalid format: ${error.message}`; + suggestion = getPatternSuggestion(path as string); + break; + case 'enum': + const allowedValues = params?.allowedValues as string[] | undefined; + message = `Invalid value. Allowed values: ${allowedValues?.join(', ')}`; + break; + case 'format': + message = `Invalid ${params?.format} format`; + suggestion = getFormatSuggestion(params?.format as string); + break; + case 'minItems': + message = `Array must have at least ${params?.limit} items`; + break; + case 'maxItems': + message = `Array can have at most ${params?.limit} items`; + break; + case 'type': + message = `Expected ${params?.type} but got ${typeof error.data}`; + break; + } + + return { + path: path === '/' ? 'root' : (path as string).replace(/^\//, ''), + message, + keyword: error.keyword as string, + params, + suggestion + }; + }); + + const primaryError = errors[0]; + const lineInfo = rawContent ? findLineForPath(primaryError.path, rawContent) : null; + + return new ValidationError( + `Manifest validation failed: ${primaryError.message}`, + { + field: primaryError.path, + line: lineInfo?.line, + column: lineInfo?.column, + suggestion: primaryError.suggestion || getSuggestionForError(primaryError), + validationErrors: errors + } + ); + } + + format(verbose: boolean = false): string { + let output = super.format(verbose); + + const validationErrors = this.details.validationErrors as Array<{ path: string; message: string }> | undefined; + if (validationErrors && validationErrors.length > 1) { + output += '\n\nAdditional validation errors:'; + validationErrors.slice(1).forEach((error, index) => { + output += `\n${index + 2}. ${error.path}: ${error.message}`; + }); + } + + return output; + } +} + +function findLineForPath(path: string, rawContent: string): { line: number; column: number } | null { + if (path === '/' || path === '' || path === 'root') return null; + + const lines = rawContent.split('\n'); + const parts = path.split('/').filter(p => p !== ''); + const searchKey = parts[parts.length - 1]; + + // Handle array indices + const cleanKey = searchKey.replace(/\[\d+\]$/, ''); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const keyIndex = line.indexOf(`"${cleanKey}"`); + if (keyIndex !== -1) { + return { line: i + 1, column: keyIndex + 1 }; + } + } + + return null; +} + +function getSuggestionForError(error: { keyword?: string; params?: Record; path?: string }): string | undefined { + if (error.keyword === 'required') { + return getRequiredPropertySuggestion(error.params?.missingProperty as string); + } + if (error.keyword === 'pattern' && error.path) { + return getPatternSuggestion(error.path); + } + if (error.keyword === 'format') { + return getFormatSuggestion(error.params?.format as string); + } + return undefined; +} + +function getRequiredPropertySuggestion(property: string): string { + const suggestions: Record = { + 'schema': 'Add "schema": "https://mcp.dev/schema/1.0" at the root level', + 'id': 'Add "id": "your-connector-name" (lowercase, hyphens only)', + 'tools': 'Add "tools": [] array with at least one tool definition', + '_contextmesh': 'Add "_contextmesh": { version, tags, language, repo } section', + 'version': 'Add "version": "1.0.0" in the _contextmesh section', + 'tags': 'Add "tags": ["category"] in the _contextmesh section (e.g., ["github", "api"])', + 'language': 'Add "language": "typescript" (or python, rust, go, java)', + 'repo': 'Add "repo": "https://github.com/..." with your repository URL', + 'name': 'Add "name": "Tool Name" in the tool definition', + 'description': 'Add "description": "What this tool does" in the tool definition' + }; + + return suggestions[property] || `Add the required "${property}" property`; +} + +function getPatternSuggestion(path: string): string { + if (!path || typeof path !== 'string') { + return 'Value must match the required pattern'; + } + + if (path.includes('/id') || path.includes('id')) { + return 'Connector ID must contain only lowercase letters, numbers, and hyphens (e.g., "my-connector")'; + } + if (path.includes('/tags') || path.includes('tags')) { + return 'Tags must contain only lowercase letters, numbers, and hyphens (e.g., "github", "api-client")'; + } + if (path.includes('/version') || path.includes('version')) { + return 'Version must follow semantic versioning format: MAJOR.MINOR.PATCH (e.g., "1.0.0")'; + } + if (path.includes('/checksum')) { + return 'Checksum must be in format "sha256:64-hex-characters"'; + } + return 'Value must match the required pattern'; +} + +function getFormatSuggestion(format: string): string { + const suggestions: Record = { + 'email': 'Use a valid email address (e.g., "user@example.com")', + 'uri': 'Use a valid URL starting with http:// or https:// (e.g., "https://github.com/user/repo")', + 'date-time': 'Use ISO 8601 format (e.g., "2024-01-01T00:00:00Z")', + 'hostname': 'Use a valid hostname (e.g., "example.com")', + 'ipv4': 'Use a valid IPv4 address (e.g., "192.168.1.1")', + 'ipv6': 'Use a valid IPv6 address' + }; + + return suggestions[format] || `Value must be a valid ${format}`; +} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index ee1cc71..6320e00 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -11,8 +11,8 @@ export interface ConnectorManifest { export interface Tool { name: string; description: string; - input_schema?: any; - output_schema?: any; + input_schema?: Record; + output_schema?: Record; } export interface Auth { diff --git a/src/utils/manifest.ts b/src/utils/manifest.ts index 5e995da..1500480 100644 --- a/src/utils/manifest.ts +++ b/src/utils/manifest.ts @@ -2,6 +2,7 @@ import { existsSync, writeFileSync, readFileSync } from 'fs'; import { join, basename } from 'path'; import { ConnectorManifest } from '../types'; import chalk from 'chalk'; +import { FileSystemError } from '../errors'; export async function createManifestIfMissing(directory: string): Promise { const manifestPath = join(directory, 'connector.mcp.json'); @@ -60,6 +61,26 @@ export async function createManifestIfMissing(directory: string): Promise { const { directory, registryUrl = 'https://api.contextmesh.io', token } = options; if (!token) { - throw new Error('Authentication token required. Set CONTEXTMESH_TOKEN environment variable or use --token flag.'); + throw AuthenticationError.missingToken(); } // Load and validate manifest @@ -55,7 +57,9 @@ async function createZipArchive(directory: string, outputPath: string): Promise< resolve(checksum); }); - archive.on('error', reject); + archive.on('error', (err) => { + reject(FileSystemError.cannotCreateZip(err.message)); + }); archive.on('data', (chunk) => hash.update(chunk)); archive.pipe(output); @@ -108,24 +112,37 @@ async function uploadToRegistry( }; try { - // Step 1: Create connector metadata - const createResponse = await axios.post( - `${registryUrl}/v1/connectors`, - { - manifest: publishManifest, - readme: await loadReadme(dirname(zipPath)) + // Step 1: Create connector metadata with retry + const createResponse = await withRetry( + async () => { + return await axios.post( + `${registryUrl}/v1/connectors`, + { + manifest: publishManifest, + readme: await loadReadme(dirname(zipPath)) + }, + { + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + } + ); }, { - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json' + maxAttempts: 3, + onRetry: (attempt, error, delay) => { + console.log(chalk.yellow(`\n⟳ Retrying after ${delay / 1000}s (attempt ${attempt}/3)...`)); + if (error instanceof NetworkError) { + console.log(chalk.gray(` Reason: ${error.message}`)); + } } } ); const { id, version, upload_url } = createResponse.data; - // Step 2: Upload artifact to presigned URL + // Step 2: Upload artifact to presigned URL (no retry for uploads to avoid duplicates) if (upload_url) { const fileStream = createReadStream(zipPath); await axios.put(upload_url, fileStream, { @@ -143,8 +160,9 @@ async function uploadToRegistry( }; } catch (error) { if (axios.isAxiosError(error)) { - const message = error.response?.data?.detail || error.message; - throw new Error(`Registry error: ${message}`); + // Use the actual request URL if available, otherwise fall back to registryUrl + const endpoint = error.config?.url || `${registryUrl}/v1/connectors`; + throw NetworkError.fromAxiosError(error, endpoint); } throw error; } diff --git a/src/utils/retry.ts b/src/utils/retry.ts new file mode 100644 index 0000000..602a956 --- /dev/null +++ b/src/utils/retry.ts @@ -0,0 +1,70 @@ +import { NetworkError } from '../errors'; + +export interface RetryOptions { + maxAttempts?: number; + baseDelay?: number; + maxDelay?: number; + factor?: number; + onRetry?: (attempt: number, error: Error, delay: number) => void; +} + +/** + * Retry a function with exponential backoff + */ +export async function withRetry( + fn: () => Promise, + options: RetryOptions = {} +): Promise { + const { + maxAttempts = 3, + baseDelay = 1000, + maxDelay = 30000, + factor = 2, + onRetry + } = options; + + let lastError: Error | undefined; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await fn(); + } catch (error) { + lastError = error as Error; + + // Check if error is retryable + const isRetryable = error instanceof NetworkError && error.isRetryable(); + + if (!isRetryable || attempt === maxAttempts) { + throw error; + } + + // Calculate delay with exponential backoff + let delay = baseDelay * Math.pow(factor, attempt - 1); + + // Use retry-after header if available (only override exponential backoff if explicitly set) + if (error instanceof NetworkError && error.details.retryAfter) { + delay = (error.details.retryAfter as number) * 1000; // Convert to milliseconds + } + + // Cap at maximum delay + delay = Math.min(delay, maxDelay); + + // Call retry callback if provided + if (onRetry) { + onRetry(attempt, error, delay); + } + + // Wait before retrying + await sleep(delay); + } + } + + throw lastError || new Error('Retry failed'); +} + +/** + * Sleep for a specified number of milliseconds + */ +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} \ No newline at end of file diff --git a/src/utils/validator.ts b/src/utils/validator.ts index ba88925..e5a6ae0 100644 --- a/src/utils/validator.ts +++ b/src/utils/validator.ts @@ -4,6 +4,7 @@ import { readFileSync } from 'fs'; import { ConnectorManifest } from '../types'; import { loadManifest } from './manifest'; import chalk from 'chalk'; +import { ValidationError } from '../errors'; // Inline schema for now - in production, this would be loaded from contextmesh-core/schema const mcpPackageSchema = { @@ -92,13 +93,6 @@ const mcpPackageSchema = { } }; -interface ValidationError { - path: string; - message: string; - line?: number; - column?: number; - suggestion?: string; -} export async function validateManifest(manifestPath: string): Promise { console.log(chalk.blue('šŸ“‹ Validating connector manifest...')); @@ -119,27 +113,7 @@ export async function validateManifest(manifestPath: string): Promise { - console.error(chalk.red(`${index + 1}. ${error.path}`)); - console.error(chalk.red(` ${error.message}`)); - - if (error.line) { - console.error(chalk.gray(` Line ${error.line}${error.column ? `, Column ${error.column}` : ''}`)); - } - - if (error.suggestion) { - console.error(chalk.yellow(` šŸ’” Suggestion: ${error.suggestion}`)); - } - console.error(''); - }); - - console.error(chalk.red('Fix these issues and try again.')); - throw new Error('Manifest validation failed'); + throw ValidationError.fromAjvErrors(errors, rawContent); } // Additional semantic validation @@ -149,135 +123,41 @@ export async function validateManifest(manifestPath: string): Promise { - const path = error.instancePath || error.schemaPath || '/'; - let message = error.message || 'Unknown error'; - let suggestion: string | undefined; - - // Enhance error messages with helpful suggestions - switch (error.keyword) { - case 'required': - const missingProp = error.params?.missingProperty; - message = `Missing required property: ${missingProp}`; - suggestion = getRequiredPropertySuggestion(missingProp); - break; - case 'pattern': - message = `Invalid format: ${error.message}`; - suggestion = getPatternSuggestion(error.schemaPath); - break; - case 'enum': - const allowedValues = error.params?.allowedValues; - message = `Invalid value. Allowed values: ${allowedValues?.join(', ')}`; - break; - case 'format': - message = `Invalid ${error.params?.format} format: ${error.message}`; - suggestion = getFormatSuggestion(error.params?.format); - break; - case 'minItems': - message = `Array must have at least ${error.params?.limit} items`; - break; - case 'maxItems': - message = `Array can have at most ${error.params?.limit} items`; - break; - } - - // Try to find line number for this path - const lineInfo = findLineForPath(path, lines); - - return { - path: path === '/' ? 'root' : path.replace(/^\//, ''), - message, - line: lineInfo?.line, - column: lineInfo?.column, - suggestion - }; - }); -} - -function findLineForPath(path: string, lines: string[]): { line: number; column: number } | null { - if (path === '/' || path === '') return null; - - // Convert JSON path to property search - const parts = path.replace(/^\//, '').split('/'); - const searchKey = parts[parts.length - 1]; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - if (line.includes(`"${searchKey}"`)) { - const column = line.indexOf(`"${searchKey}"`) + 1; - return { line: i + 1, column }; - } - } - - return null; -} - -function getRequiredPropertySuggestion(property: string): string { - const suggestions: Record = { - 'schema': 'Add "schema": "https://mcp.dev/schema/1.0"', - 'id': 'Add "id": "your-connector-name" (lowercase, hyphens only)', - 'tools': 'Add "tools": [] array with at least one tool definition', - '_contextmesh': 'Add "_contextmesh": {} with version, tags, language, and repo', - 'version': 'Add "version": "1.0.0" in _contextmesh section', - 'tags': 'Add "tags": ["category"] in _contextmesh section', - 'language': 'Add "language": "typescript" in _contextmesh section', - 'repo': 'Add "repo": "https://github.com/..." in _contextmesh section', - 'name': 'Add "name": "Tool Name"', - 'description': 'Add "description": "What this tool does"' - }; - - return suggestions[property] || `Add the required "${property}" property`; -} - -function getPatternSuggestion(schemaPath: string): string { - if (schemaPath.includes('/id/')) { - return 'Connector ID must be lowercase letters, numbers, and hyphens only (e.g., "my-connector")'; - } - if (schemaPath.includes('/tags/')) { - return 'Tags must be lowercase letters, numbers, and hyphens only (e.g., "github", "api-client")'; - } - if (schemaPath.includes('/version/')) { - return 'Version must follow semantic versioning (e.g., "1.0.0")'; - } - return 'Value must match the required pattern'; -} - -function getFormatSuggestion(format: string): string { - const suggestions: Record = { - 'email': 'Must be a valid email address (e.g., "user@example.com")', - 'uri': 'Must be a valid URL (e.g., "https://github.com/user/repo")', - 'date-time': 'Must be a valid ISO 8601 date-time' - }; - - return suggestions[format] || `Must be a valid ${format}`; -} - function performSemanticValidation(manifest: ConnectorManifest): void { // Validate tools array is not empty if (!manifest.tools || manifest.tools.length === 0) { - throw new Error('Connector must define at least one tool'); + throw new ValidationError('Connector must define at least one tool', { + field: 'tools', + suggestion: 'Add at least one tool to the "tools" array' + }); } // Validate tool names are unique const toolNames = manifest.tools.map(tool => tool.name); - const duplicates = toolNames.filter((name, index) => toolNames.indexOf(name) !== index); + const duplicates = toolNames.filter((name: string, index: number) => toolNames.indexOf(name) !== index); if (duplicates.length > 0) { - throw new Error(`Duplicate tool names found: ${duplicates.join(', ')}`); + throw new ValidationError(`Duplicate tool names found: ${duplicates.join(', ')}`, { + field: 'tools', + suggestion: 'Each tool must have a unique name' + }); } // Validate _contextmesh section exists (already checked by schema) if (!manifest._contextmesh) { - throw new Error('Missing _contextmesh metadata section'); + throw new ValidationError('Missing _contextmesh metadata section', { + field: '_contextmesh', + suggestion: 'Add "_contextmesh" section with version, tags, language, and repo' + }); } // Validate version format more strictly const version = manifest._contextmesh.version; const versionParts = version.split('.'); if (versionParts.length !== 3 || versionParts.some(part => isNaN(Number(part)))) { - throw new Error(`Invalid version format: ${version}. Must be semantic version (e.g., "1.0.0")`); + throw new ValidationError(`Invalid version format: ${version}`, { + field: '_contextmesh.version', + suggestion: 'Use semantic versioning format (e.g., "1.0.0")' + }); } // Validate repo URL points to a valid git repository