diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index 6b2a5b30b..5f4f96e93 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -235,10 +235,10 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise r.status === 'error'); - const errorMsg = - errorResult?.error && typeof errorResult.error === 'string' ? errorResult.error : 'Identity setup failed'; + const errorResult = identityResult.results.find(r => r.status === 'error' && r.error); + const errorMsg = errorResult?.error?.message ?? 'Identity setup failed'; endStep('error', errorMsg); logger.finalize(false); - return { success: false, error: new Error(errorMsg), logPath: logger.getRelativeLogPath() }; + + return { + success: false, + error: errorResult?.error ?? new Error('unknown error occurred when setting up api key providers'), + logPath: logger.getRelativeLogPath(), + }; } identityKmsKeyArn = identityResult.kmsKeyArn; @@ -302,12 +306,17 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise r.status === 'error'); + const errorResult = oauthResult.results.find(r => r.status === 'error' && r.error); logger.log(`OAuth setup error: ${errorResult?.error ?? 'unknown'}`, 'error'); const errorMsg = 'OAuth credential setup failed. Check the log for details.'; endStep('error', errorMsg); logger.finalize(false); - return { success: false, error: new Error(errorMsg), logPath: logger.getRelativeLogPath() }; + + return { + success: false, + error: errorResult?.error ?? new Error(`an unexpected error ocurred when setting up oauth providers`), + logPath: logger.getRelativeLogPath(), + }; } // Collect OAuth credential ARNs for deployed state @@ -336,13 +345,13 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise e.message).join('; '); endStep('error', errorMsgs); logger.log(`Payment credential setup errors: ${errorMsgs}`, 'error'); logger.finalize(false); return { success: false, - error: new Error(`Payment setup failed: ${errorMsgs}`), + error: paymentPreDeployResult.errors[0] ?? new Error('payment deploy preflight steps failed'), logPath: logger.getRelativeLogPath(), }; } @@ -405,7 +414,7 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise { vi.clearAllMocks(); }); - it('returns null when no credentials exist (file missing is fine)', () => { + it('returns ok when no credentials exist (file missing is fine)', () => { mockExistsSync.mockReturnValue(false); const result = assertEnvFileExists(makeSpec(), BASE_DIR); - expect(result).toBeNull(); + expect(result.success).toBe(true); }); - it('returns null when file exists', () => { + it('returns ok when file exists', () => { mockExistsSync.mockReturnValue(true); const spec = makeSpec({ credentials: [{ name: 'mykey', authorizerType: 'ApiKeyCredentialProvider' } as any], }); const result = assertEnvFileExists(spec, BASE_DIR); - expect(result).toBeNull(); + expect(result.success).toBe(true); }); it('lists ApiKey env vars when file is missing', () => { @@ -57,8 +57,11 @@ describe('assertEnvFileExists', () => { credentials: [{ name: 'openai', authorizerType: 'ApiKeyCredentialProvider' } as any], }); const result = assertEnvFileExists(spec, BASE_DIR); - expect(result).toContain('agentcore/.env.local not found'); - expect(result).toContain('AGENTCORE_CREDENTIAL_OPENAI'); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain('agentcore/.env.local not found'); + expect(result.error.message).toContain('AGENTCORE_CREDENTIAL_OPENAI'); + } }); it('lists OAuth2 env vars when file is missing', () => { @@ -67,8 +70,11 @@ describe('assertEnvFileExists', () => { credentials: [{ name: 'google-oauth', authorizerType: 'OAuthCredentialProvider' } as any], }); const result = assertEnvFileExists(spec, BASE_DIR); - expect(result).toContain('AGENTCORE_CREDENTIAL_GOOGLE_OAUTH_CLIENT_ID'); - expect(result).toContain('AGENTCORE_CREDENTIAL_GOOGLE_OAUTH_CLIENT_SECRET'); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain('AGENTCORE_CREDENTIAL_GOOGLE_OAUTH_CLIENT_ID'); + expect(result.error.message).toContain('AGENTCORE_CREDENTIAL_GOOGLE_OAUTH_CLIENT_SECRET'); + } }); it('lists CoinbaseCDP payment env vars when file is missing', () => { @@ -83,9 +89,12 @@ describe('assertEnvFileExists', () => { ], }); const result = assertEnvFileExists(spec, BASE_DIR); - expect(result).toContain('AGENTCORE_CREDENTIAL_PAYMGR_CDPCONN_CDP_API_KEY_ID'); - expect(result).toContain('AGENTCORE_CREDENTIAL_PAYMGR_CDPCONN_CDP_API_KEY_SECRET'); - expect(result).toContain('AGENTCORE_CREDENTIAL_PAYMGR_CDPCONN_CDP_WALLET_SECRET'); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain('AGENTCORE_CREDENTIAL_PAYMGR_CDPCONN_CDP_API_KEY_ID'); + expect(result.error.message).toContain('AGENTCORE_CREDENTIAL_PAYMGR_CDPCONN_CDP_API_KEY_SECRET'); + expect(result.error.message).toContain('AGENTCORE_CREDENTIAL_PAYMGR_CDPCONN_CDP_WALLET_SECRET'); + } }); it('lists StripePrivy payment env vars when file is missing', () => { @@ -102,10 +111,13 @@ describe('assertEnvFileExists', () => { ], }); const result = assertEnvFileExists(spec, BASE_DIR); - expect(result).toContain('APP_ID'); - expect(result).toContain('APP_SECRET'); - expect(result).toContain('AUTHORIZATION_PRIVATE_KEY'); - expect(result).toContain('AUTHORIZATION_ID'); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain('APP_ID'); + expect(result.error.message).toContain('APP_SECRET'); + expect(result.error.message).toContain('AUTHORIZATION_PRIVATE_KEY'); + expect(result.error.message).toContain('AUTHORIZATION_ID'); + } }); it('combines all credential types in a single error', () => { @@ -124,9 +136,12 @@ describe('assertEnvFileExists', () => { ], }); const result = assertEnvFileExists(spec, BASE_DIR); - expect(result).toContain('AGENTCORE_CREDENTIAL_OPENAI'); - expect(result).toContain('AGENTCORE_CREDENTIAL_GOOGLE_CLIENT_ID'); - expect(result).toContain('AGENTCORE_CREDENTIAL_PAYMGR_CDPCONN_CDP_API_KEY_ID'); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.message).toContain('AGENTCORE_CREDENTIAL_OPENAI'); + expect(result.error.message).toContain('AGENTCORE_CREDENTIAL_GOOGLE_CLIENT_ID'); + expect(result.error.message).toContain('AGENTCORE_CREDENTIAL_PAYMGR_CDPCONN_CDP_API_KEY_ID'); + } }); }); diff --git a/src/cli/operations/deploy/__tests__/pre-deploy-identity.test.ts b/src/cli/operations/deploy/__tests__/pre-deploy-identity.test.ts index c7ff81c84..a26d60ecf 100644 --- a/src/cli/operations/deploy/__tests__/pre-deploy-identity.test.ts +++ b/src/cli/operations/deploy/__tests__/pre-deploy-identity.test.ts @@ -74,6 +74,13 @@ vi.mock('../../../../lib/index.js', () => ({ } }, readEnvFile: mockReadEnvFile, + MissingCredentialsError: class MissingCredentialsError extends Error { + constructor(message: string) { + super(message); + this.name = 'MissingCredentialsError'; + } + }, + toError: (e: unknown) => (e instanceof Error ? e : new Error(String(e))), })); vi.mock('../../../aws/index.js', () => ({ @@ -359,7 +366,7 @@ describe('setupOAuth2Providers', () => { expect(result.hasErrors).toBe(false); expect(result.results).toHaveLength(1); expect(result.results[0]!.status).toBe('skipped'); - expect(result.results[0]!.error).toContain('Missing'); + expect(result.results[0]!.error?.message).toContain('Missing'); }); it('returns error on failure', async () => { diff --git a/src/cli/operations/deploy/__tests__/pre-deploy-payments.test.ts b/src/cli/operations/deploy/__tests__/pre-deploy-payments.test.ts index 1d333a231..5b6bb2317 100644 --- a/src/cli/operations/deploy/__tests__/pre-deploy-payments.test.ts +++ b/src/cli/operations/deploy/__tests__/pre-deploy-payments.test.ts @@ -42,6 +42,13 @@ vi.mock('../../../../lib', () => ({ } }, readEnvFile: mockReadEnvFile, + toError: (e: unknown) => (e instanceof Error ? e : new Error(String(e))), + MissingCredentialsError: class MissingCredentialsError extends Error { + constructor(message: string) { + super(message); + this.name = 'MissingCredentialsError'; + } + }, })); vi.mock('fs', () => ({ @@ -218,9 +225,9 @@ describe('setupPaymentCredentialProviders', () => { expect(result.hasErrors).toBe(true); expect(result.errors).toHaveLength(1); - expect(result.errors[0]).toContain('Missing CDP credentials'); - expect(result.errors[0]).toContain('AGENTCORE_CREDENTIAL_MY_CDP_CRED_API_KEY_SECRET'); - expect(result.errors[0]).toContain('AGENTCORE_CREDENTIAL_MY_CDP_CRED_WALLET_SECRET'); + expect(result.errors[0]?.message).toContain('Missing CDP credentials'); + expect(result.errors[0]?.message).toContain('AGENTCORE_CREDENTIAL_MY_CDP_CRED_API_KEY_SECRET'); + expect(result.errors[0]?.message).toContain('AGENTCORE_CREDENTIAL_MY_CDP_CRED_WALLET_SECRET'); expect(mockCreatePaymentCredentialProvider).not.toHaveBeenCalled(); }); @@ -240,10 +247,10 @@ describe('setupPaymentCredentialProviders', () => { expect(result.hasErrors).toBe(true); expect(result.errors).toHaveLength(1); - expect(result.errors[0]).toContain('Missing StripePrivy credentials'); - expect(result.errors[0]).toContain('AGENTCORE_CREDENTIAL_MY_STRIPE_CRED_APP_SECRET'); - expect(result.errors[0]).toContain('AGENTCORE_CREDENTIAL_MY_STRIPE_CRED_AUTHORIZATION_PRIVATE_KEY'); - expect(result.errors[0]).toContain('AGENTCORE_CREDENTIAL_MY_STRIPE_CRED_AUTHORIZATION_ID'); + expect(result.errors[0]?.message).toContain('Missing StripePrivy credentials'); + expect(result.errors[0]?.message).toContain('AGENTCORE_CREDENTIAL_MY_STRIPE_CRED_APP_SECRET'); + expect(result.errors[0]?.message).toContain('AGENTCORE_CREDENTIAL_MY_STRIPE_CRED_AUTHORIZATION_PRIVATE_KEY'); + expect(result.errors[0]?.message).toContain('AGENTCORE_CREDENTIAL_MY_STRIPE_CRED_AUTHORIZATION_ID'); expect(mockCreatePaymentCredentialProvider).not.toHaveBeenCalled(); }); diff --git a/src/cli/operations/deploy/pre-deploy-identity.ts b/src/cli/operations/deploy/pre-deploy-identity.ts index 84cb9186a..d69e722d1 100644 --- a/src/cli/operations/deploy/pre-deploy-identity.ts +++ b/src/cli/operations/deploy/pre-deploy-identity.ts @@ -1,4 +1,12 @@ -import { SecureCredentials, readEnvFile } from '../../../lib'; +import { + AwsCredentialsError, + MissingCredentialsError, + SecureCredentials, + ServiceQuotaError, + ValidationError, + readEnvFile, + toError, +} from '../../../lib'; import type { AgentCoreProjectSpec, Credential } from '../../../schema'; import { getCredentialProvider } from '../../aws'; import { @@ -23,6 +31,7 @@ import { updateApiKeyProvider, updateOAuth2Provider, } from '../identity'; +import { type Result, err, ok } from '@/lib/result'; import { BedrockAgentCoreControlClient, GetTokenVaultCommand } from '@aws-sdk/client-bedrock-agentcore-control'; import { CreateKeyCommand, KMSClient } from '@aws-sdk/client-kms'; import { existsSync } from 'fs'; @@ -36,7 +45,7 @@ export interface ApiKeyProviderSetupResult { providerName: string; status: 'created' | 'updated' | 'exists' | 'skipped' | 'error'; credentialProviderArn?: string; - error?: string; + error?: Error; } export interface PreDeployIdentityResult { @@ -86,7 +95,7 @@ export async function setupApiKeyProviders(options: SetupApiKeyProvidersOptions) { providerName: 'TokenVault', status: 'error', - error: `Failed to configure KMS: ${kmsResult.error}`, + error: kmsResult.error, }, ], hasErrors: true, @@ -117,7 +126,7 @@ async function setupTokenVaultKms( region: string, credentials: ReturnType, projectSpec: AgentCoreProjectSpec -): Promise<{ success: boolean; keyArn?: string; error?: string }> { +): Promise> { try { const controlClient = new BedrockAgentCoreControlClient({ region, credentials }); @@ -128,7 +137,7 @@ async function setupTokenVaultKms( vaultResponse.kmsConfiguration?.keyType === 'CustomerManagedKey' && vaultResponse.kmsConfiguration.kmsKeyArn ) { - return { success: true, keyArn: vaultResponse.kmsConfiguration.kmsKeyArn }; + return ok({ keyArn: vaultResponse.kmsConfiguration.kmsKeyArn }); } } catch { // Vault may not exist yet or access denied — fall through to create key @@ -144,17 +153,14 @@ async function setupTokenVaultKms( ); const keyArn = response.KeyMetadata?.Arn; if (!keyArn) { - return { success: false, error: 'Failed to create KMS key' }; + return err(new Error('Failed to create KMS key')); } const result = await setTokenVaultKmsKey(controlClient, keyArn); - if (!result.success) { - return { success: false, error: result.error }; - } - - return { success: true, keyArn }; + if (!result.success) return result; + return ok({ keyArn }); } catch (error) { - return { success: false, error: error instanceof Error ? error.message : String(error) }; + return err(toError(error)); } } @@ -170,7 +176,7 @@ async function setupApiKeyCredentialProvider( return { providerName: credential.name, status: 'skipped', - error: `No ${envVarName} found in agentcore/.env.local`, + error: new Error(`No ${envVarName} found in agentcore/.env.local`), }; } @@ -182,8 +188,8 @@ async function setupApiKeyCredentialProvider( return { providerName: credential.name, status: updateResult.success ? 'updated' : 'error', - credentialProviderArn: updateResult.credentialProviderArn, - error: updateResult.error, + credentialProviderArn: updateResult.success ? updateResult.credentialProviderArn : undefined, + error: updateResult.success ? undefined : updateResult.error, }; } @@ -191,22 +197,25 @@ async function setupApiKeyCredentialProvider( return { providerName: credential.name, status: createResult.success ? 'created' : 'error', - credentialProviderArn: createResult.credentialProviderArn, - error: createResult.error, + credentialProviderArn: createResult.success ? createResult.credentialProviderArn : undefined, + error: createResult.success ? undefined : createResult.error, }; } catch (error) { // Provide clearer error message for AWS credentials issues - let errorMessage: string; if (isNoCredentialsError(error)) { - errorMessage = `AWS credentials not found. ${await getAwsLoginGuidance()}`; - } else { - errorMessage = error instanceof Error ? error.message : String(error); + return { + providerName: credential.name, + status: 'error', + error: new AwsCredentialsError(`AWS credentials not found. ${await getAwsLoginGuidance()}`, undefined, { + cause: error, + }), + }; } return { providerName: credential.name, status: 'error', - error: errorMessage, + error: toError(error), }; } } @@ -302,15 +311,19 @@ export function getAllCredentials(projectSpec: AgentCoreProjectSpec): MissingCre * so the user can populate the file in one shot rather than discovering missing vars * one at a time across separate setup steps. */ -export function assertEnvFileExists(projectSpec: AgentCoreProjectSpec, configBaseDir: string): string | null { +export function assertEnvFileExists(projectSpec: AgentCoreProjectSpec, configBaseDir: string): Result { const allCredentials = getAllCredentials(projectSpec); - if (allCredentials.length === 0) return null; + if (allCredentials.length === 0) return ok(); const envFilePath = join(configBaseDir, '.env.local'); - if (existsSync(envFilePath)) return null; + if (existsSync(envFilePath)) return ok(); const varList = allCredentials.map(c => ` ${c.envVarName}`).join('\n'); - return `agentcore/.env.local not found. Credentials require environment variables.\n\nRequired variables:\n${varList}\n\nTo fix: create agentcore/.env.local with the variables above, or re-run the relevant 'agentcore add' command to enter credentials interactively.`; + return err( + new MissingCredentialsError( + `agentcore/.env.local not found. Credentials require environment variables.\n\nRequired variables:\n${varList}\n\nTo fix: create agentcore/.env.local with the variables above, or re-run the relevant 'agentcore add' command to enter credentials interactively.` + ) + ); } // ───────────────────────────────────────────────────────────────────────────── @@ -320,7 +333,7 @@ export function assertEnvFileExists(projectSpec: AgentCoreProjectSpec, configBas export interface OAuth2ProviderSetupResult { providerName: string; status: 'created' | 'updated' | 'skipped' | 'error'; - error?: string; + error?: Error; credentialProviderArn?: string; clientSecretArn?: string; callbackUrl?: string; @@ -379,7 +392,7 @@ async function setupSingleOAuth2Provider( credentials: SecureCredentials ): Promise { if (credential.authorizerType !== 'OAuthCredentialProvider') { - return { providerName: credential.name, status: 'error', error: 'Invalid credential type' }; + return { providerName: credential.name, status: 'error', error: new ValidationError('Invalid credential type') }; } const nameKey = credential.name.toUpperCase().replace(/-/g, '_'); @@ -393,7 +406,7 @@ async function setupSingleOAuth2Provider( return { providerName: credential.name, status: 'skipped', - error: `Missing ${clientIdEnvVar} or ${clientSecretEnvVar} in agentcore/.env.local`, + error: new MissingCredentialsError(`Missing ${clientIdEnvVar} or ${clientSecretEnvVar} in agentcore/.env.local`), }; } @@ -403,7 +416,9 @@ async function setupSingleOAuth2Provider( return { providerName: credential.name, status: 'skipped', - error: `No discoveryUrl configured for "${credential.name}". Provider already exists in Identity service — credentials in .env.local will be ignored.`, + error: new MissingCredentialsError( + `No discoveryUrl configured for "${credential.name}". Provider already exists in Identity service — credentials in .env.local will be ignored.` + ), }; } @@ -423,10 +438,10 @@ async function setupSingleOAuth2Provider( return { providerName: credential.name, status: updateResult.success ? 'updated' : 'error', - error: updateResult.error, - credentialProviderArn: updateResult.result?.credentialProviderArn, - clientSecretArn: updateResult.result?.clientSecretArn, - callbackUrl: updateResult.result?.callbackUrl, + error: updateResult.success ? undefined : updateResult.error, + credentialProviderArn: updateResult.success ? updateResult.credentialProviderArn : undefined, + clientSecretArn: updateResult.success ? updateResult.clientSecretArn : undefined, + callbackUrl: updateResult.success ? updateResult.callbackUrl : undefined, }; } @@ -434,19 +449,23 @@ async function setupSingleOAuth2Provider( return { providerName: credential.name, status: createResult.success ? 'created' : 'error', - error: createResult.error, - credentialProviderArn: createResult.result?.credentialProviderArn, - clientSecretArn: createResult.result?.clientSecretArn, - callbackUrl: createResult.result?.callbackUrl, + error: createResult.success ? undefined : createResult.error, + credentialProviderArn: createResult.success ? createResult.credentialProviderArn : undefined, + clientSecretArn: createResult.success ? createResult.clientSecretArn : undefined, + callbackUrl: createResult.success ? createResult.callbackUrl : undefined, }; - } catch (error) { - let errorMessage: string; + } catch (e) { + const error = toError(e); if (isNoCredentialsError(error)) { - errorMessage = 'AWS credentials not found. Run `aws sso login` or set AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY.'; - } else { - errorMessage = error instanceof Error ? error.message : String(error); + return { + providerName: credential.name, + status: 'error', + error: new AwsCredentialsError(`AWS crdentials not found. ${await getAwsLoginGuidance()}`, undefined, { + cause: error, + }), + }; } - return { providerName: credential.name, status: 'error', error: errorMessage }; + return { providerName: credential.name, status: 'error', error: error }; } } @@ -462,7 +481,7 @@ export interface PaymentCredentialProviderResult { export interface PaymentCredentialProvidersResult { credentialProviders: Record; hasErrors: boolean; - errors: string[]; + errors: Error[]; } export interface SetupPaymentCredentialProvidersOptions { @@ -508,7 +527,9 @@ export async function setupPaymentCredentialProviders( if (!credential) { result.hasErrors = true; result.errors.push( - `Payment manager "${payment.name}" connector "${connector.name}" references credential "${credentialName}" which is not a PaymentCredentialProvider` + new ValidationError( + `Payment manager "${payment.name}" connector "${connector.name}" references credential "${credentialName}" which is not a PaymentCredentialProvider` + ) ); continue; } @@ -524,17 +545,24 @@ export async function setupPaymentCredentialProviders( credentialProviderArn, credentialProviderName: credentialName, }; - } catch (error) { - let errorMessage: string; + } catch (e) { + result.hasErrors = true; + const error = toError(e); if (isNoCredentialsError(error)) { - errorMessage = `AWS credentials not found. ${await getAwsLoginGuidance()}`; + result.errors.push( + new AwsCredentialsError(`AWS credentials not found. ${await getAwsLoginGuidance()}`, undefined, { + cause: error, + }) + ); } else if (isQuotaExceededError(error)) { - errorMessage = `Service quota exceeded. Delete unused credential providers, or request a limit increase via the AWS Service Quotas console.`; + result.errors.push( + new ServiceQuotaError( + `Service quota exceeded. Delete unused credential providers, or request a limit increase via the AWS Service Quotas console.` + ) + ); } else { - errorMessage = error instanceof Error ? error.message : String(error); + result.errors.push(error); } - result.hasErrors = true; - result.errors.push(`Credential provider for "${connector.name}": ${errorMessage}`); } } } @@ -598,7 +626,7 @@ async function createOrUpdatePaymentCredentialProvider( !authorizationPrivateKey && envVarNames.authorizationPrivateKey, !authorizationId && envVarNames.authorizationId, ].filter(Boolean); - throw new Error( + throw new MissingCredentialsError( `Missing StripePrivy credentials for connector "${connector.name}" in agentcore/.env.local: ${missing.join(', ')}` ); } @@ -624,7 +652,7 @@ async function createOrUpdatePaymentCredentialProvider( !apiKeySecret && envVarNames.apiKeySecret, !walletSecret && envVarNames.walletSecret, ].filter(Boolean); - throw new Error( + throw new MissingCredentialsError( `Missing CDP credentials for connector "${connector.name}" in agentcore/.env.local: ${missing.join(', ')}` ); } diff --git a/src/cli/operations/dev/__tests__/invoke-a2a.test.ts b/src/cli/operations/dev/__tests__/invoke-a2a.test.ts index 743c34fc1..e390af6c3 100644 --- a/src/cli/operations/dev/__tests__/invoke-a2a.test.ts +++ b/src/cli/operations/dev/__tests__/invoke-a2a.test.ts @@ -1,4 +1,4 @@ -import { ServerError } from '../../../../lib/errors/types'; +import { DevServerError } from '../../../../lib/errors/types'; import { invokeA2AStreaming } from '../invoke-a2a'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -76,7 +76,7 @@ describe('invokeA2AStreaming', () => { expect(mockFetch).toHaveBeenCalledTimes(2); }); - it('throws ServerError on HTTP error without retrying', async () => { + it('throws DevServerError on HTTP error without retrying', async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 500, @@ -84,7 +84,7 @@ describe('invokeA2AStreaming', () => { }); const gen = invokeA2AStreaming({ port: 8080, message: 'test' }); - await expect(gen.next()).rejects.toThrow(ServerError); + await expect(gen.next()).rejects.toThrow(DevServerError); }); it('handles JSON-RPC error in response', async () => { @@ -101,7 +101,7 @@ describe('invokeA2AStreaming', () => { }); const gen = invokeA2AStreaming({ port: 8080, message: 'test' }); - await expect(gen.next()).rejects.toThrow(ServerError); + await expect(gen.next()).rejects.toThrow(DevServerError); }); it('streams text from status-update events and skips duplicate artifact-update', async () => { diff --git a/src/cli/operations/dev/__tests__/invoke-mcp.test.ts b/src/cli/operations/dev/__tests__/invoke-mcp.test.ts index ccba4559c..021cb1d2d 100644 --- a/src/cli/operations/dev/__tests__/invoke-mcp.test.ts +++ b/src/cli/operations/dev/__tests__/invoke-mcp.test.ts @@ -1,4 +1,4 @@ -import { ServerError } from '../../../../lib/errors/types'; +import { DevServerError } from '../../../../lib/errors/types'; import { callMcpTool, listMcpTools } from '../invoke-mcp'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -79,14 +79,14 @@ describe('listMcpTools', () => { expect(mockFetch).toHaveBeenCalledTimes(4); }); - it('throws ServerError on HTTP error', async () => { + it('throws DevServerError on HTTP error', async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 500, text: () => 'Internal Server Error', }); - await expect(listMcpTools(8080)).rejects.toThrow(ServerError); + await expect(listMcpTools(8080)).rejects.toThrow(DevServerError); }); }); diff --git a/src/cli/operations/dev/invoke-a2a.ts b/src/cli/operations/dev/invoke-a2a.ts index 2b3d28f1d..2491297c1 100644 --- a/src/cli/operations/dev/invoke-a2a.ts +++ b/src/cli/operations/dev/invoke-a2a.ts @@ -1,4 +1,4 @@ -import { ConnectionError, ServerError } from '../../../lib/errors/types'; +import { DevServerConnectionError, DevServerError } from '../../../lib/errors/types'; import { type InvokeStreamingOptions, type SSELogger } from './invoke-types'; import { isConnectionError, sleep } from './utils'; import { randomUUID } from 'crypto'; @@ -91,7 +91,7 @@ export async function* invokeA2AStreaming(options: InvokeStreamingOptions): Asyn if (!res.ok) { const responseBody = await res.text(); - throw new ServerError(res.status, responseBody); + throw new DevServerError(res.status, responseBody); } const contentType = res.headers.get('content-type') ?? ''; @@ -111,7 +111,7 @@ export async function* invokeA2AStreaming(options: InvokeStreamingOptions): Asyn if (json.error) { const rpcError = json.error as { message?: string; code?: number }; - throw new ServerError(rpcError.code ?? 500, rpcError.message ?? 'A2A RPC error'); + throw new DevServerError(rpcError.code ?? 500, rpcError.message ?? 'A2A RPC error'); } const result = json.result as Record | undefined; @@ -126,13 +126,13 @@ export async function* invokeA2AStreaming(options: InvokeStreamingOptions): Asyn yield responseText; } } catch (e) { - if (e instanceof ServerError) throw e; + if (e instanceof DevServerError) throw e; yield responseText; } return; } catch (err) { - if (err instanceof ServerError) { + if (err instanceof DevServerError) { logger?.log?.('error', `Server error (${err.statusCode}): ${err.message}`); throw err; } @@ -154,9 +154,12 @@ export async function* invokeA2AStreaming(options: InvokeStreamingOptions): Asyn } } - const finalError = new ConnectionError(lastError?.message ?? 'Failed to connect to A2A server after retries', { - cause: lastError, - }); + const finalError = new DevServerConnectionError( + lastError?.message ?? 'Failed to connect to A2A server after retries', + { + cause: lastError, + } + ); logger?.log?.('error', `Failed to connect after ${maxRetries} attempts: ${finalError.message}`); throw finalError; } diff --git a/src/cli/operations/dev/invoke-agui.ts b/src/cli/operations/dev/invoke-agui.ts index eb11a5129..e7714252f 100644 --- a/src/cli/operations/dev/invoke-agui.ts +++ b/src/cli/operations/dev/invoke-agui.ts @@ -1,4 +1,4 @@ -import { ConnectionError, ServerError } from '../../../lib/errors/types'; +import { DevServerConnectionError, DevServerError } from '../../../lib/errors/types'; import { parseAguiSSEStream } from '../../aws/agui-parser'; import { AguiEventType } from '../../aws/agui-types'; import { type InvokeStreamingOptions } from './invoke-types'; @@ -38,7 +38,7 @@ export async function* invokeAguiStreaming(options: InvokeStreamingOptions): Asy if (!res.ok) { const responseBody = await res.text(); - throw new ServerError(res.status, responseBody); + throw new DevServerError(res.status, responseBody); } if (!res.body) { @@ -122,7 +122,7 @@ export async function* invokeAguiStreaming(options: InvokeStreamingOptions): Asy return; } catch (err) { - if (err instanceof ServerError) { + if (err instanceof DevServerError) { logger?.log?.('error', `Server error (${err.statusCode}): ${err.message}`); throw err; } @@ -148,9 +148,12 @@ export async function* invokeAguiStreaming(options: InvokeStreamingOptions): Asy } } - const finalError = new ConnectionError(lastError?.message ?? 'Failed to connect to AGUI server after retries', { - cause: lastError, - }); + const finalError = new DevServerConnectionError( + lastError?.message ?? 'Failed to connect to AGUI server after retries', + { + cause: lastError, + } + ); logger?.log?.('error', `Failed to connect after ${maxRetries} attempts: ${finalError.message}`); throw finalError; } diff --git a/src/cli/operations/dev/invoke-mcp.ts b/src/cli/operations/dev/invoke-mcp.ts index 6e861ef43..2d322f857 100644 --- a/src/cli/operations/dev/invoke-mcp.ts +++ b/src/cli/operations/dev/invoke-mcp.ts @@ -1,4 +1,4 @@ -import { ConnectionError, ServerError } from '../../../lib/errors/types'; +import { DevServerConnectionError, DevServerError } from '../../../lib/errors/types'; import { parseJsonRpcResponse } from '../../../lib/utils/json-rpc'; import { type SSELogger } from './invoke-types'; import { isConnectionError, sleep } from './utils'; @@ -58,7 +58,7 @@ export async function listMcpTools( if (!initRes.ok) { const body = await initRes.text(); - throw new ServerError(initRes.status, body); + throw new DevServerError(initRes.status, body); } // Extract session ID from response header @@ -103,7 +103,7 @@ export async function listMcpTools( if (!listRes.ok) { const body = await listRes.text(); - throw new ServerError(listRes.status, body); + throw new DevServerError(listRes.status, body); } const listResponseText = await listRes.text(); @@ -122,7 +122,7 @@ export async function listMcpTools( sessionId: sessionId ?? undefined, }; } catch (err) { - if (err instanceof ServerError) { + if (err instanceof DevServerError) { logger?.log?.('error', `Server error (${err.statusCode}): ${err.message}`); throw err; } @@ -144,9 +144,12 @@ export async function listMcpTools( } } - const finalError = new ConnectionError(lastError?.message ?? 'Failed to connect to MCP server after retries', { - cause: lastError, - }); + const finalError = new DevServerConnectionError( + lastError?.message ?? 'Failed to connect to MCP server after retries', + { + cause: lastError, + } + ); logger?.log?.('error', `Failed to connect after ${maxRetries} attempts: ${finalError.message}`); throw finalError; } @@ -187,7 +190,7 @@ export async function callMcpTool( if (!res.ok) { const responseBody = await res.text(); - throw new ServerError(res.status, responseBody); + throw new DevServerError(res.status, responseBody); } const responseText = await res.text(); diff --git a/src/cli/operations/dev/invoke.ts b/src/cli/operations/dev/invoke.ts index a91378ab3..fcde8f0bf 100644 --- a/src/cli/operations/dev/invoke.ts +++ b/src/cli/operations/dev/invoke.ts @@ -1,4 +1,4 @@ -import { ConnectionError, ServerError } from '../../../lib/errors/types'; +import { DevServerConnectionError, DevServerError } from '../../../lib/errors/types'; import { invokeA2AStreaming } from './invoke-a2a'; import { invokeAguiStreaming } from './invoke-agui'; import { type InvokeStreamingOptions, type SSELogger } from './invoke-types'; @@ -106,7 +106,7 @@ export async function* invokeAgentStreaming( if (!res.ok) { const body = await res.text(); - throw new ServerError(res.status, body); + throw new DevServerError(res.status, body); } if (!res.body) { @@ -177,8 +177,8 @@ export async function* invokeAgentStreaming( return; } catch (err) { - // Re-throw ServerError directly — no retries for HTTP errors - if (err instanceof ServerError) { + // Re-throw DevServerError directly — no retries for HTTP errors + if (err instanceof DevServerError) { logger?.log?.('error', `Server error (${err.statusCode}): ${err.message}`); throw err; } @@ -202,9 +202,12 @@ export async function* invokeAgentStreaming( } // Log final failure after all retries exhausted with full details - const finalError = new ConnectionError(lastError?.message ?? 'Failed to connect to dev server after retries', { - cause: lastError, - }); + const finalError = new DevServerConnectionError( + lastError?.message ?? 'Failed to connect to dev server after retries', + { + cause: lastError, + } + ); logger?.log?.('error', `Failed to connect after ${maxRetries} attempts: ${finalError.message}`); throw finalError; } @@ -271,7 +274,7 @@ export async function invokeAgent(portOrOptions: number | InvokeOptions, message if (!res.ok) { const body = await res.text(); - throw new ServerError(res.status, body); + throw new DevServerError(res.status, body); } const text = await res.text(); @@ -287,8 +290,8 @@ export async function invokeAgent(portOrOptions: number | InvokeOptions, message // Handle plain JSON response (non-streaming frameworks) return extractResult(text); } catch (err) { - // Re-throw ServerError directly — no retries for HTTP errors - if (err instanceof ServerError) { + // Re-throw DevServerError directly — no retries for HTTP errors + if (err instanceof DevServerError) { logger?.log?.('error', `Server error (${err.statusCode}): ${err.message}`); throw err; } @@ -312,9 +315,12 @@ export async function invokeAgent(portOrOptions: number | InvokeOptions, message } // Log final failure after all retries exhausted with full details - const finalError = new ConnectionError(lastError?.message ?? 'Failed to connect to dev server after retries', { - cause: lastError, - }); + const finalError = new DevServerConnectionError( + lastError?.message ?? 'Failed to connect to dev server after retries', + { + cause: lastError, + } + ); logger?.log?.('error', `Failed to connect after ${maxRetries} attempts: ${finalError.message}`); throw finalError; } diff --git a/src/cli/operations/identity/__tests__/api-key-credential-provider.test.ts b/src/cli/operations/identity/__tests__/api-key-credential-provider.test.ts index e7cbafb62..8018b6c1e 100644 --- a/src/cli/operations/identity/__tests__/api-key-credential-provider.test.ts +++ b/src/cli/operations/identity/__tests__/api-key-credential-provider.test.ts @@ -65,28 +65,51 @@ describe('apiKeyProviderExists', () => { describe('createApiKeyProvider', () => { afterEach(() => vi.clearAllMocks()); - it('returns success on creation', async () => { - mockSend.mockResolvedValue({}); + it('returns success with credentialProviderArn', async () => { + mockSend.mockResolvedValueOnce({}); // create + mockSend.mockResolvedValueOnce({ credentialProviderArn: 'arn:aws:bedrock:us-east-1:123:provider/prov' }); // get const result = await createApiKeyProvider(makeMockClient(), 'prov', 'key123'); - expect(result).toEqual({ success: true }); + expect(result).toEqual({ + success: true, + credentialProviderArn: 'arn:aws:bedrock:us-east-1:123:provider/prov', + }); }); - it('returns success on ConflictException (idempotent)', async () => { + it('returns success on ConflictException with ARN from get', async () => { const err = new Error('conflict'); Object.defineProperty(err, 'name', { value: 'ConflictException' }); - mockSend.mockRejectedValue(err); + mockSend.mockRejectedValueOnce(err); + mockSend.mockResolvedValueOnce({ credentialProviderArn: 'arn:aws:bedrock:us-east-1:123:provider/prov' }); // get const result = await createApiKeyProvider(makeMockClient(), 'prov', 'key123'); - expect(result).toEqual({ success: true }); + expect(result).toEqual({ + success: true, + credentialProviderArn: 'arn:aws:bedrock:us-east-1:123:provider/prov', + }); }); - it('returns success on ResourceAlreadyExistsException', async () => { + it('returns success on ResourceAlreadyExistsException with ARN from get', async () => { const err = new Error('exists'); Object.defineProperty(err, 'name', { value: 'ResourceAlreadyExistsException' }); - mockSend.mockRejectedValue(err); + mockSend.mockRejectedValueOnce(err); + mockSend.mockResolvedValueOnce({ credentialProviderArn: 'arn:aws:bedrock:us-east-1:123:provider/prov' }); // get + + const result = await createApiKeyProvider(makeMockClient(), 'prov', 'key123'); + + expect(result).toEqual({ + success: true, + credentialProviderArn: 'arn:aws:bedrock:us-east-1:123:provider/prov', + }); + }); + + it('returns success without ARN when conflict get fails', async () => { + const conflictErr = new Error('conflict'); + Object.defineProperty(conflictErr, 'name', { value: 'ConflictException' }); + mockSend.mockRejectedValueOnce(conflictErr); + mockSend.mockRejectedValueOnce(new Error('get failed')); // get fails const result = await createApiKeyProvider(makeMockClient(), 'prov', 'key123'); @@ -99,17 +122,23 @@ describe('createApiKeyProvider', () => { const result = await createApiKeyProvider(makeMockClient(), 'prov', 'key123'); expect(result.success).toBe(false); - expect(result.error).toBe('unexpected'); + expect((result as { success: false; error: Error }).error.message).toBe('unexpected'); }); }); describe('updateApiKeyProvider', () => { afterEach(() => vi.clearAllMocks()); - it('returns success on update', async () => { - mockSend.mockResolvedValue({}); + it('returns success with credentialProviderArn', async () => { + mockSend.mockResolvedValueOnce({}); // update + mockSend.mockResolvedValueOnce({ credentialProviderArn: 'arn:aws:bedrock:us-east-1:123:provider/prov' }); // get + + const result = await updateApiKeyProvider(makeMockClient(), 'prov', 'newkey'); - expect(await updateApiKeyProvider(makeMockClient(), 'prov', 'newkey')).toEqual({ success: true }); + expect(result).toEqual({ + success: true, + credentialProviderArn: 'arn:aws:bedrock:us-east-1:123:provider/prov', + }); }); it('returns failure on error', async () => { @@ -118,7 +147,7 @@ describe('updateApiKeyProvider', () => { const result = await updateApiKeyProvider(makeMockClient(), 'prov', 'newkey'); expect(result.success).toBe(false); - expect(result.error).toBe('update fail'); + expect((result as { success: false; error: Error }).error.message).toBe('update fail'); }); }); @@ -137,6 +166,6 @@ describe('setTokenVaultKmsKey', () => { const result = await setTokenVaultKmsKey(makeMockClient(), 'arn:aws:kms:key'); expect(result.success).toBe(false); - expect(result.error).toBe('kms fail'); + expect((result as { success: false; error: Error }).error.message).toBe('kms fail'); }); }); diff --git a/src/cli/operations/identity/__tests__/oauth2-credential-provider.test.ts b/src/cli/operations/identity/__tests__/oauth2-credential-provider.test.ts index 23523dde8..2de9f32a7 100644 --- a/src/cli/operations/identity/__tests__/oauth2-credential-provider.test.ts +++ b/src/cli/operations/identity/__tests__/oauth2-credential-provider.test.ts @@ -81,11 +81,9 @@ describe('createOAuth2Provider', () => { expect(result).toEqual({ success: true, - result: { - credentialProviderArn: 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/test-provider', - clientSecretArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:test-secret', - callbackUrl: 'https://callback.example.com', - }, + credentialProviderArn: 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/test-provider', + clientSecretArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:test-secret', + callbackUrl: 'https://callback.example.com', }); }); @@ -104,9 +102,7 @@ describe('createOAuth2Provider', () => { expect(result).toEqual({ success: true, - result: { - credentialProviderArn: 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/test-provider', - }, + credentialProviderArn: 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/test-provider', }); }); @@ -125,9 +121,7 @@ describe('createOAuth2Provider', () => { expect(result).toEqual({ success: true, - result: { - credentialProviderArn: 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/test-provider', - }, + credentialProviderArn: 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/test-provider', }); }); @@ -137,7 +131,7 @@ describe('createOAuth2Provider', () => { const result = await createOAuth2Provider(makeMockClient(), mockParams); expect(result.success).toBe(false); - expect(result.error).toBe('unexpected error'); + expect((result as { success: false; error: Error }).error.message).toBe('unexpected error'); }); it('returns error when no credentialProviderArn in response', async () => { @@ -145,10 +139,10 @@ describe('createOAuth2Provider', () => { const result = await createOAuth2Provider(makeMockClient(), mockParams); - expect(result).toEqual({ - success: false, - error: 'No credential provider ARN in response', - }); + expect(result.success).toBe(false); + expect((result as { success: false; error: Error }).error.message).toBe( + 'missing credentialProviderArn in response' + ); }); }); @@ -167,11 +161,9 @@ describe('getOAuth2Provider', () => { expect(result).toEqual({ success: true, - result: { - credentialProviderArn: 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/test-provider', - clientSecretArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:test-secret', - callbackUrl: 'https://callback.example.com', - }, + credentialProviderArn: 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/test-provider', + clientSecretArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:test-secret', + callbackUrl: 'https://callback.example.com', }); }); @@ -181,7 +173,7 @@ describe('getOAuth2Provider', () => { const result = await getOAuth2Provider(makeMockClient(), 'test-provider'); expect(result.success).toBe(false); - expect(result.error).toBe('get failed'); + expect((result as { success: false; error: Error }).error.message).toBe('get failed'); }); it('returns error when no ARN', async () => { @@ -189,10 +181,10 @@ describe('getOAuth2Provider', () => { const result = await getOAuth2Provider(makeMockClient(), 'test-provider'); - expect(result).toEqual({ - success: false, - error: 'No credential provider ARN in response', - }); + expect(result.success).toBe(false); + expect((result as { success: false; error: Error }).error.message).toBe( + 'missing credentialProviderArn in response' + ); }); }); @@ -218,10 +210,8 @@ describe('updateOAuth2Provider', () => { expect(result).toEqual({ success: true, - result: { - credentialProviderArn: 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/test-provider', - clientSecretArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:test-secret', - }, + credentialProviderArn: 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/test-provider', + clientSecretArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:test-secret', }); }); @@ -231,6 +221,6 @@ describe('updateOAuth2Provider', () => { const result = await updateOAuth2Provider(makeMockClient(), mockParams); expect(result.success).toBe(false); - expect(result.error).toBe('update failed'); + expect((result as { success: false; error: Error }).error.message).toBe('update failed'); }); }); diff --git a/src/cli/operations/identity/api-key-credential-provider.ts b/src/cli/operations/identity/api-key-credential-provider.ts index 36ef82619..0d8040371 100644 --- a/src/cli/operations/identity/api-key-credential-provider.ts +++ b/src/cli/operations/identity/api-key-credential-provider.ts @@ -5,6 +5,8 @@ * as CDK constructs. These operations run as a pre-deploy step outside the * main CDK synthesis/deploy path. */ +import { type Result, toError } from '@/lib'; +import { err, ok } from '@/lib/result'; import { BedrockAgentCoreControlClient, CreateApiKeyCredentialProviderCommand, @@ -40,7 +42,7 @@ export async function createApiKeyProvider( client: BedrockAgentCoreControlClient, providerName: string, apiKey: string -): Promise<{ success: boolean; credentialProviderArn?: string; error?: string }> { +): Promise> { try { await client.send( new CreateApiKeyCredentialProviderCommand({ @@ -50,21 +52,18 @@ export async function createApiKeyProvider( ); // Create response doesn't include credentialProviderArn — fetch it const getResponse = await client.send(new GetApiKeyCredentialProviderCommand({ name: providerName })); - return { success: true, credentialProviderArn: getResponse.credentialProviderArn }; + return ok({ credentialProviderArn: getResponse.credentialProviderArn }); } catch (error) { const errorName = (error as { name?: string }).name; if (errorName === 'ConflictException' || errorName === 'ResourceAlreadyExistsException') { try { const getResponse = await client.send(new GetApiKeyCredentialProviderCommand({ name: providerName })); - return { success: true, credentialProviderArn: getResponse.credentialProviderArn }; + return ok({ credentialProviderArn: getResponse.credentialProviderArn }); } catch { - return { success: true }; + return ok(); } } - return { - success: false, - error: error instanceof Error ? error.message : String(error), - }; + return err(toError(error)); } } @@ -75,7 +74,7 @@ export async function updateApiKeyProvider( client: BedrockAgentCoreControlClient, providerName: string, apiKey: string -): Promise<{ success: boolean; credentialProviderArn?: string; error?: string }> { +): Promise> { try { await client.send( new UpdateApiKeyCredentialProviderCommand({ @@ -85,12 +84,9 @@ export async function updateApiKeyProvider( ); // Update response doesn't include credentialProviderArn — fetch it const getResponse = await client.send(new GetApiKeyCredentialProviderCommand({ name: providerName })); - return { success: true, credentialProviderArn: getResponse.credentialProviderArn }; + return ok({ credentialProviderArn: getResponse.credentialProviderArn }); } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : String(error), - }; + return err(toError(error)); } } @@ -102,7 +98,7 @@ export async function setTokenVaultKmsKey( client: BedrockAgentCoreControlClient, kmsKeyArn: string, tokenVaultId?: string -): Promise<{ success: boolean; error?: string }> { +): Promise { try { await client.send( new SetTokenVaultCMKCommand({ @@ -113,11 +109,8 @@ export async function setTokenVaultKmsKey( }, }) ); - return { success: true }; + return ok(); } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : String(error), - }; + return err(toError(error)); } } diff --git a/src/cli/operations/identity/oauth2-credential-provider.ts b/src/cli/operations/identity/oauth2-credential-provider.ts index cd037670b..d500460a3 100644 --- a/src/cli/operations/identity/oauth2-credential-provider.ts +++ b/src/cli/operations/identity/oauth2-credential-provider.ts @@ -5,6 +5,8 @@ * as CDK constructs. These operations run as a pre-deploy step outside the * main CDK synthesis/deploy path. */ +import { type Result, ServiceError, toError } from '@/lib'; +import { err, ok } from '@/lib/result'; import { BedrockAgentCoreControlClient, CreateOauth2CredentialProviderCommand, @@ -14,11 +16,11 @@ import { UpdateOauth2CredentialProviderCommand, } from '@aws-sdk/client-bedrock-agentcore-control'; -export interface OAuth2ProviderResult { +export type OAuth2ProviderResult = Result<{ credentialProviderArn: string; clientSecretArn?: string; callbackUrl?: string; -} +}>; export interface OAuth2ProviderParams { name: string; @@ -36,13 +38,13 @@ function extractResult(response: { credentialProviderArn?: string; clientSecretArn?: { secretArn?: string }; callbackUrl?: string; -}): OAuth2ProviderResult | undefined { - if (!response.credentialProviderArn) return undefined; - return { +}): OAuth2ProviderResult { + if (!response.credentialProviderArn) return err(new ServiceError('missing credentialProviderArn in response')); + return ok({ credentialProviderArn: response.credentialProviderArn, clientSecretArn: response.clientSecretArn?.secretArn, callbackUrl: response.callbackUrl, - }; + }); } /** @@ -93,19 +95,16 @@ function buildOAuth2Config(params: OAuth2ProviderParams) { export async function createOAuth2Provider( client: BedrockAgentCoreControlClient, params: OAuth2ProviderParams -): Promise<{ success: boolean; result?: OAuth2ProviderResult; error?: string }> { +): Promise { try { const response = await client.send(new CreateOauth2CredentialProviderCommand(buildOAuth2Config(params))); let result = extractResult(response); - if (!result) { + if (!result.success) { // Create response may not include credentialProviderArn — fetch it const getResult = await getOAuth2Provider(client, params.name); - result = getResult.result; + result = getResult; } - if (!result) { - return { success: false, error: 'No credential provider ARN in response' }; - } - return { success: true, result }; + return result; } catch (error) { const errorName = (error as { name?: string }).name; if (errorName === 'ConflictException' || errorName === 'ResourceAlreadyExistsException') { @@ -113,10 +112,7 @@ export async function createOAuth2Provider( // create call. Fall back to update so the user's credentials are always applied. return updateOAuth2Provider(client, params); } - return { - success: false, - error: error instanceof Error ? error.message : String(error), - }; + return err(toError(error)); } } @@ -126,19 +122,12 @@ export async function createOAuth2Provider( export async function getOAuth2Provider( client: BedrockAgentCoreControlClient, name: string -): Promise<{ success: boolean; result?: OAuth2ProviderResult; error?: string }> { +): Promise { try { const response = await client.send(new GetOauth2CredentialProviderCommand({ name })); - const result = extractResult(response); - if (!result) { - return { success: false, error: 'No credential provider ARN in response' }; - } - return { success: true, result }; + return extractResult(response); } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : String(error), - }; + return err(toError(error)); } } @@ -148,22 +137,16 @@ export async function getOAuth2Provider( export async function updateOAuth2Provider( client: BedrockAgentCoreControlClient, params: OAuth2ProviderParams -): Promise<{ success: boolean; result?: OAuth2ProviderResult; error?: string }> { +): Promise { try { const response = await client.send(new UpdateOauth2CredentialProviderCommand(buildOAuth2Config(params))); let result = extractResult(response); - if (!result) { + if (!result.success) { const getResult = await getOAuth2Provider(client, params.name); - result = getResult.result; - } - if (!result) { - return { success: false, error: 'No credential provider ARN in response' }; + result = getResult; } - return { success: true, result }; + return result; } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : String(error), - }; + return err(toError(error)); } } diff --git a/src/cli/telemetry/error.ts b/src/cli/telemetry/error.ts index 91bc83029..3fa31385c 100644 --- a/src/cli/telemetry/error.ts +++ b/src/cli/telemetry/error.ts @@ -4,10 +4,11 @@ import type { z } from 'zod'; type ErrorNameValue = z.infer; -// Maps common AWS SDK error names to a category and source. -const SDK_ERROR_MAP: Record = { +// Maps common error names to a category and source. +const COMMON_ERROR_MAP: Record = { AccessDeniedException: { category: 'AccessDeniedError', source: 'user' }, AccessDenied: { category: 'AccessDeniedError', source: 'user' }, + ForbiddenException: { category: 'AccessDeniedError', source: 'user' }, ExpiredToken: { category: 'AwsCredentialsError', source: 'user' }, ExpiredTokenException: { category: 'AwsCredentialsError', source: 'user' }, InvalidClientTokenId: { category: 'AwsCredentialsError', source: 'user' }, @@ -19,6 +20,14 @@ const SDK_ERROR_MAP: Record e.message).join('; '); logger.endStep('error', errorMsg); updateStepByLabel(LABEL_PAYMENTS, { status: 'error', error: `Payment setup failed: ${errorMsg}` }); setPhase('error'); @@ -800,9 +800,9 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { } else if (result.status === 'exists') { logger.log(`API key provider exists: ${result.providerName}`); } else if (result.status === 'skipped') { - logger.log(`Skipped ${result.providerName}: ${result.error}`); + logger.log(`Skipped ${result.providerName}: ${result.error?.message}`); } else if (result.status === 'error') { - logger.log(`Error for ${result.providerName}: ${result.error}`); + logger.log(`Error for ${result.providerName}: ${result.error?.message}`); } } @@ -843,9 +843,9 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { } else if (result.status === 'updated') { logger.log(`Updated OAuth provider: ${result.providerName}`); } else if (result.status === 'skipped') { - logger.log(`Skipped ${result.providerName}: ${result.error}`); + logger.log(`Skipped ${result.providerName}: ${result.error?.message}`); } else if (result.status === 'error') { - logger.log(`Error for ${result.providerName}: ${result.error}`); + logger.log(`Error for ${result.providerName}: ${result.error?.message}`); } } diff --git a/src/cli/tui/hooks/useDevServer.ts b/src/cli/tui/hooks/useDevServer.ts index 17d0b969d..7e5d6964b 100644 --- a/src/cli/tui/hooks/useDevServer.ts +++ b/src/cli/tui/hooks/useDevServer.ts @@ -1,5 +1,5 @@ import { findConfigRoot } from '../../../lib'; -import { ConnectionError, ServerError } from '../../../lib/errors/types'; +import { DevServerConnectionError, DevServerError } from '../../../lib/errors/types'; import type { AgentCoreProjectSpec, ProtocolMode } from '../../../schema'; import { detectContainerRuntime } from '../../external-requirements'; import { DevLogger } from '../../logging/dev-logger'; @@ -381,11 +381,11 @@ export function useDevServer(options: { let errorMsg: string; let showHint = false; - if (err instanceof ServerError) { + if (err instanceof DevServerError) { // HTTP error — use the response body directly (avoids stderr race condition) errorMsg = err.message || `Server error (${err.statusCode})`; showHint = true; - } else if (err instanceof ConnectionError) { + } else if (err instanceof DevServerConnectionError) { // Connection failed after retries — check stderr logs for crash context const recentErrors = logsRef.current .filter(l => l.level === 'error') diff --git a/src/lib/errors/__tests__/config.test.ts b/src/lib/errors/__tests__/config.test.ts index 4246d2459..b9b9c280f 100644 --- a/src/lib/errors/__tests__/config.test.ts +++ b/src/lib/errors/__tests__/config.test.ts @@ -1,5 +1,5 @@ import { - ConfigError, + BaseError, ConfigNotFoundError, ConfigParseError, ConfigReadError, @@ -21,9 +21,9 @@ describe('ConfigNotFoundError', () => { expect(err.fileType).toBe('targets'); }); - it('is instance of ConfigError and Error', () => { + it('is instance of BaseError and Error', () => { const err = new ConfigNotFoundError('/path', 'project'); - expect(err).toBeInstanceOf(ConfigError); + expect(err).toBeInstanceOf(BaseError); expect(err).toBeInstanceOf(Error); }); @@ -83,14 +83,14 @@ describe('ConfigParseError', () => { }); describe('ConfigValidationError', () => { - it('stores zodError and is instance of ConfigError', () => { + it('stores zodError and is instance of BaseError', () => { const schema = z.object({ name: z.string() }); const result = schema.safeParse({ name: 123 }); expect(result.success).toBe(false); if (!result.success) { const err = new ConfigValidationError('/path', 'project', result.error); expect(err.zodError).toBe(result.error); - expect(err).toBeInstanceOf(ConfigError); + expect(err).toBeInstanceOf(BaseError); expect(err).toBeInstanceOf(Error); } }); diff --git a/src/lib/errors/types.ts b/src/lib/errors/types.ts index 4e488ffca..e7e7f7c33 100644 --- a/src/lib/errors/types.ts +++ b/src/lib/errors/types.ts @@ -109,6 +109,15 @@ export class AwsCredentialsError extends BaseError { } } +/** + * Error thrown when non-AWS credentials are missing but required.. (ex. API Keys) + */ +export class MissingCredentialsError extends BaseError { + constructor(message: string, options?: BaseErrorOptions) { + super(message, { defaultSource: 'user', ...options }); + } +} + /** * Error indicating a packaging operation failed. */ @@ -159,7 +168,7 @@ export class UnsupportedLanguageError extends PackagingError { /** * Base class for all config-related errors. */ -export abstract class ConfigError extends BaseError { +abstract class ConfigError extends BaseError { protected constructor(message: string, options?: BaseErrorOptions) { super(message, { defaultSource: 'user', ...options }); } @@ -229,8 +238,6 @@ export class ConfigParseError extends ConfigError { } } -// --- Client errors --- - /** * Error indicating git repository initialization failed. */ @@ -263,7 +270,7 @@ export class TimeoutError extends BaseError { /** * Error thrown when the dev server returns a non-OK HTTP response. */ -export class ServerError extends BaseError { +export class DevServerError extends BaseError { constructor( public readonly statusCode: number, body: string, @@ -276,12 +283,39 @@ export class ServerError extends BaseError { /** * Error thrown when the connection to the dev server fails. */ -export class ConnectionError extends BaseError { +export class DevServerConnectionError extends BaseError { constructor(message: string, options?: BaseErrorOptions) { super(message, { defaultSource: 'client', ...options }); } } +/** + * Error indicating an AWS service gave back a 5xx or malforned response. + */ +export class ServiceError extends BaseError { + constructor(message: string, options?: BaseErrorOptions) { + super(message, { defaultSource: 'service', ...options }); + } +} + +/** + * Error indicating a request was throttled or rate-limited. + */ +export class ThrottlingError extends BaseError { + constructor(message: string, options?: BaseErrorOptions) { + super(message, { defaultSource: 'service', ...options }); + } +} + +/** + * Error indicating a service quota or limit was exceeded. + */ +export class ServiceQuotaError extends BaseError { + constructor(message: string, options?: BaseErrorOptions) { + super(message, { defaultSource: 'service', ...options }); + } +} + /** * Error indicating polling timed out. */ diff --git a/src/lib/result.ts b/src/lib/result.ts index b79a9919a..edf7cd321 100644 --- a/src/lib/result.ts +++ b/src/lib/result.ts @@ -1,5 +1,7 @@ // eslint-disable-next-line @typescript-eslint/consistent-type-definitions -- discriminated union member; interface would allow declaration merging which breaks type narrowing type FailureResult = { success: false; error: E }; + +type SuccessResult> = { success: true } & T; /** * Discriminated union for fallible operations, inspired by Rust's Result. * @@ -13,7 +15,7 @@ type FailureResult = { success: false; error: E }; */ // eslint-disable-next-line @typescript-eslint/no-empty-object-type export type Result = {}, E extends Error = Error> = - | ({ success: true } & T) + | SuccessResult | FailureResult; /** @@ -62,3 +64,19 @@ export function failureResult(e: E): FailureResult { error: e, }; } + +export function ok(): SuccessResult>; +export function ok>(data: T): SuccessResult; +export function ok(data?: Record) { + return { + success: true, + ...(data ?? {}), + }; +} + +export function err(e: E): FailureResult { + return { + success: false, + error: e, + }; +}