Skip to content

Commit 0f84525

Browse files
authored
fix #180: use BloomreachBuddyError everywhere with structured codes and recovery hints (#192)
- Create packages/core/src/errors.ts with ERROR_CODES, BloomreachBuddyError (with details field), BloomreachApiError, and toErrorPayload() - Convert all 692 throw new Error() across 39 service files to BloomreachBuddyError with appropriate error codes - Map validation errors to ACTION_PRECONDITION_FAILED - Map missing API credentials to CONFIG_MISSING with details.missing - Map 'not yet implemented' to ACTION_PRECONDITION_FAILED with details.not_implemented flag - Map unexpected API responses to API_ERROR - Update MCP toolResults.ts to return structured payloads with code, message, details, and recovery_hint - Update MCP toolArgs.ts, toolSchema.ts, bloomreach-mcp.ts - Update CLI bloomreach.ts - All 9738 tests pass, lint clean, typecheck clean, build succeeds
1 parent 86939f1 commit 0f84525

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

48 files changed

+1159
-1848
lines changed

packages/cli/src/bin/bloomreach.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { Command } from 'commander';
44
import { input, password, confirm } from '@inquirer/prompts';
55
import { readFileSync } from 'node:fs';
6+
import { BloomreachBuddyError } from "@bloomreach-buddy/core";
67
import { homedir } from 'node:os';
78
import { join } from 'node:path';
89
import * as core from '@bloomreach-buddy/core';
@@ -6321,7 +6322,7 @@ projectSettings
63216322
.action(async (options: { project: string; accepted: string; note?: string; json?: boolean }) => {
63226323
try {
63236324
if (options.accepted !== 'true' && options.accepted !== 'false') {
6324-
throw new Error('Option --accepted must be "true" or "false".');
6325+
throw new BloomreachBuddyError('ACTION_PRECONDITION_FAILED', 'Option --accepted must be "true" or "false".');
63256326
}
63266327

63276328
const service = new BloomreachProjectSettingsService(options.project);

packages/core/src/bloomreachAccessManagement.ts

Lines changed: 25 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { validateProject } from './bloomreachDashboards.js';
2+
import { BloomreachBuddyError } from './errors.js';
23
import type { BloomreachApiConfig } from './bloomreachApiClient.js';
34

45
// ---------------------------------------------------------------------------
@@ -113,51 +114,47 @@ const MAX_API_KEY_NAME_LENGTH = 200;
113114
export function validateEmail(email: string): string {
114115
const trimmed = email.trim();
115116
if (trimmed.length === 0) {
116-
throw new Error('Email must not be empty.');
117+
throw new BloomreachBuddyError('ACTION_PRECONDITION_FAILED', 'Email must not be empty.');
117118
}
118119

119120
const atIndex = trimmed.indexOf('@');
120121
if (atIndex <= 0 || atIndex === trimmed.length - 1) {
121-
throw new Error('Email must contain "@" with text on both sides.');
122+
throw new BloomreachBuddyError('ACTION_PRECONDITION_FAILED', 'Email must contain "@" with text on both sides.');
122123
}
123124

124125
return trimmed;
125126
}
126127

127128
export function validateMemberRole(role: string): TeamMemberRole {
128129
if (!TEAM_MEMBER_ROLES.includes(role as TeamMemberRole)) {
129-
throw new Error(
130-
`role must be one of: ${TEAM_MEMBER_ROLES.join(', ')} (got "${role}").`,
131-
);
130+
throw new BloomreachBuddyError('ACTION_PRECONDITION_FAILED', `role must be one of: ${TEAM_MEMBER_ROLES.join(', ')} (got "${role}").`);
132131
}
133132
return role as TeamMemberRole;
134133
}
135134

136135
export function validateMemberId(id: string): string {
137136
const trimmed = id.trim();
138137
if (trimmed.length === 0) {
139-
throw new Error('Member ID must not be empty.');
138+
throw new BloomreachBuddyError('ACTION_PRECONDITION_FAILED', 'Member ID must not be empty.');
140139
}
141140
return trimmed;
142141
}
143142

144143
export function validateApiKeyName(name: string): string {
145144
const trimmed = name.trim();
146145
if (trimmed.length === 0) {
147-
throw new Error('API key name must not be empty.');
146+
throw new BloomreachBuddyError('ACTION_PRECONDITION_FAILED', 'API key name must not be empty.');
148147
}
149148
if (trimmed.length > MAX_API_KEY_NAME_LENGTH) {
150-
throw new Error(
151-
`API key name must not exceed ${MAX_API_KEY_NAME_LENGTH} characters (got ${trimmed.length}).`,
152-
);
149+
throw new BloomreachBuddyError('ACTION_PRECONDITION_FAILED', `API key name must not exceed ${MAX_API_KEY_NAME_LENGTH} characters (got ${trimmed.length}).`);
153150
}
154151
return trimmed;
155152
}
156153

157154
export function validateApiKeyId(id: string): string {
158155
const trimmed = id.trim();
159156
if (trimmed.length === 0) {
160-
throw new Error('API key ID must not be empty.');
157+
throw new BloomreachBuddyError('ACTION_PRECONDITION_FAILED', 'API key ID must not be empty.');
161158
}
162159
return trimmed;
163160
}
@@ -179,9 +176,9 @@ function requireApiConfig(
179176
operation: string,
180177
): BloomreachApiConfig {
181178
if (!config) {
182-
throw new Error(
183-
`${operation} requires API credentials. ` +
184-
'Set BLOOMREACH_PROJECT_TOKEN, BLOOMREACH_API_KEY_ID, and BLOOMREACH_API_SECRET environment variables.',
179+
throw new BloomreachBuddyError('CONFIG_MISSING', `${operation} requires API credentials. ` +
180+
'Set BLOOMREACH_PROJECT_TOKEN, BLOOMREACH_API_KEY_ID, and BLOOMREACH_API_SECRET environment variables.',
181+
{ missing: ['BLOOMREACH_PROJECT_TOKEN', 'BLOOMREACH_API_KEY_ID', 'BLOOMREACH_API_SECRET'] },
185182
);
186183
}
187184
return config;
@@ -210,10 +207,8 @@ class InviteTeamMemberExecutor implements AccessActionExecutor {
210207
_payload: Record<string, unknown>,
211208
): Promise<Record<string, unknown>> {
212209
void this.apiConfig;
213-
throw new Error(
214-
'InviteTeamMemberExecutor: not yet implemented. ' +
215-
'Team member invitation is only available through the Bloomreach Engagement UI.',
216-
);
210+
throw new BloomreachBuddyError('ACTION_PRECONDITION_FAILED', 'InviteTeamMemberExecutor: not yet implemented. ' +
211+
'Team member invitation is only available through the Bloomreach Engagement UI.', { not_implemented: true });
217212
}
218213
}
219214

@@ -229,10 +224,8 @@ class UpdateMemberRoleExecutor implements AccessActionExecutor {
229224
_payload: Record<string, unknown>,
230225
): Promise<Record<string, unknown>> {
231226
void this.apiConfig;
232-
throw new Error(
233-
'UpdateMemberRoleExecutor: not yet implemented. ' +
234-
'Member role updates are only available through the Bloomreach Engagement UI.',
235-
);
227+
throw new BloomreachBuddyError('ACTION_PRECONDITION_FAILED', 'UpdateMemberRoleExecutor: not yet implemented. ' +
228+
'Member role updates are only available through the Bloomreach Engagement UI.', { not_implemented: true });
236229
}
237230
}
238231

@@ -248,10 +241,8 @@ class RemoveTeamMemberExecutor implements AccessActionExecutor {
248241
_payload: Record<string, unknown>,
249242
): Promise<Record<string, unknown>> {
250243
void this.apiConfig;
251-
throw new Error(
252-
'RemoveTeamMemberExecutor: not yet implemented. ' +
253-
'Team member removal is only available through the Bloomreach Engagement UI.',
254-
);
244+
throw new BloomreachBuddyError('ACTION_PRECONDITION_FAILED', 'RemoveTeamMemberExecutor: not yet implemented. ' +
245+
'Team member removal is only available through the Bloomreach Engagement UI.', { not_implemented: true });
255246
}
256247
}
257248

@@ -267,10 +258,8 @@ class CreateApiKeyExecutor implements AccessActionExecutor {
267258
_payload: Record<string, unknown>,
268259
): Promise<Record<string, unknown>> {
269260
void this.apiConfig;
270-
throw new Error(
271-
'CreateApiKeyExecutor: not yet implemented. ' +
272-
'API key creation is only available through the Bloomreach Engagement UI.',
273-
);
261+
throw new BloomreachBuddyError('ACTION_PRECONDITION_FAILED', 'CreateApiKeyExecutor: not yet implemented. ' +
262+
'API key creation is only available through the Bloomreach Engagement UI.', { not_implemented: true });
274263
}
275264
}
276265

@@ -286,10 +275,8 @@ class DeleteApiKeyExecutor implements AccessActionExecutor {
286275
_payload: Record<string, unknown>,
287276
): Promise<Record<string, unknown>> {
288277
void this.apiConfig;
289-
throw new Error(
290-
'DeleteApiKeyExecutor: not yet implemented. ' +
291-
'API key deletion is only available through the Bloomreach Engagement UI.',
292-
);
278+
throw new BloomreachBuddyError('ACTION_PRECONDITION_FAILED', 'DeleteApiKeyExecutor: not yet implemented. ' +
279+
'API key deletion is only available through the Bloomreach Engagement UI.', { not_implemented: true });
293280
}
294281
}
295282

@@ -332,21 +319,17 @@ export class BloomreachAccessManagementService {
332319
if (input !== undefined) {
333320
validateProject(input.project);
334321
}
335-
throw new Error(
336-
'listTeamMembers: not yet implemented. the Bloomreach API does not provide an endpoint for team members. ' +
337-
'Team members must be managed through the Bloomreach Engagement UI (navigate to Project Settings > Project Team).',
338-
);
322+
throw new BloomreachBuddyError('ACTION_PRECONDITION_FAILED', 'listTeamMembers: not yet implemented. the Bloomreach API does not provide an endpoint for team members. ' +
323+
'Team members must be managed through the Bloomreach Engagement UI (navigate to Project Settings > Project Team).', { not_implemented: true });
339324
}
340325

341326
async listApiKeys(input?: ListApiKeysInput): Promise<BloomreachApiKey[]> {
342327
void this.apiConfig;
343328
if (input !== undefined) {
344329
validateProject(input.project);
345330
}
346-
throw new Error(
347-
'listApiKeys: not yet implemented. the Bloomreach API does not provide an endpoint for API keys. ' +
348-
'API keys must be managed through the Bloomreach Engagement UI (navigate to Project Settings > API).',
349-
);
331+
throw new BloomreachBuddyError('ACTION_PRECONDITION_FAILED', 'listApiKeys: not yet implemented. the Bloomreach API does not provide an endpoint for API keys. ' +
332+
'API keys must be managed through the Bloomreach Engagement UI (navigate to Project Settings > API).', { not_implemented: true });
350333
}
351334

352335
prepareInviteTeamMember(input: InviteTeamMemberInput): PreparedAccessAction {

packages/core/src/bloomreachApiClient.ts

Lines changed: 17 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,17 @@
1+
// ---------------------------------------------------------------------------
2+
// Re-export error hierarchy from canonical location
3+
// ---------------------------------------------------------------------------
4+
5+
export {
6+
BloomreachBuddyError,
7+
BloomreachApiError,
8+
toErrorPayload,
9+
ERROR_CODES,
10+
} from './errors.js';
11+
export type { BloomreachErrorCode, ErrorCode, ErrorPayload } from './errors.js';
12+
13+
import { BloomreachBuddyError, BloomreachApiError } from './errors.js';
14+
115
// ---------------------------------------------------------------------------
216
// Types & config
317
// ---------------------------------------------------------------------------
@@ -11,53 +25,6 @@ export interface BloomreachApiConfig {
1125

1226
const DEFAULT_BASE_URL = 'https://api.exponea.com';
1327

14-
// ---------------------------------------------------------------------------
15-
// Error hierarchy
16-
// ---------------------------------------------------------------------------
17-
18-
export type BloomreachErrorCode =
19-
| 'CONFIG_MISSING'
20-
| 'TIMEOUT'
21-
| 'RATE_LIMITED'
22-
| 'API_ERROR'
23-
| 'NETWORK_ERROR'
24-
| 'AUTH_REQUIRED'
25-
| 'CAPTCHA_OR_CHALLENGE'
26-
| 'SESSION_EXPIRED'
27-
| 'PROFILE_LOCKED'
28-
| 'ACTION_PRECONDITION_FAILED'
29-
| 'TARGET_NOT_FOUND';
30-
31-
/**
32-
* Base error class for all Bloomreach Buddy errors.
33-
* Every error carries a machine-readable `code` for programmatic handling.
34-
*/
35-
export class BloomreachBuddyError extends Error {
36-
readonly code: BloomreachErrorCode;
37-
38-
constructor(code: BloomreachErrorCode, message: string) {
39-
super(message);
40-
this.name = 'BloomreachBuddyError';
41-
this.code = code;
42-
}
43-
}
44-
45-
/**
46-
* Thrown on non-2xx API responses. Carries the HTTP status code and the
47-
* parsed (or raw) response body for inspection.
48-
*/
49-
export class BloomreachApiError extends BloomreachBuddyError {
50-
readonly statusCode: number;
51-
readonly responseBody: unknown;
52-
53-
constructor(message: string, statusCode: number, responseBody: unknown) {
54-
super('API_ERROR', message);
55-
this.name = 'BloomreachApiError';
56-
this.statusCode = statusCode;
57-
this.responseBody = responseBody;
58-
}
59-
}
60-
6128
// ---------------------------------------------------------------------------
6229
// Config resolution
6330
// ---------------------------------------------------------------------------
@@ -84,18 +51,21 @@ export function resolveApiConfig(
8451
throw new BloomreachBuddyError(
8552
'CONFIG_MISSING',
8653
'Bloomreach project token is required. Set BLOOMREACH_PROJECT_TOKEN or pass --project-token.',
54+
{ missing: ['BLOOMREACH_PROJECT_TOKEN'] },
8755
);
8856
}
8957
if (apiKeyId.trim().length === 0) {
9058
throw new BloomreachBuddyError(
9159
'CONFIG_MISSING',
9260
'Bloomreach API key ID is required. Set BLOOMREACH_API_KEY_ID or pass --api-key-id.',
61+
{ missing: ['BLOOMREACH_API_KEY_ID'] },
9362
);
9463
}
9564
if (apiSecret.trim().length === 0) {
9665
throw new BloomreachBuddyError(
9766
'CONFIG_MISSING',
9867
'Bloomreach API secret is required. Set BLOOMREACH_API_SECRET or pass --api-secret.',
68+
{ missing: ['BLOOMREACH_API_SECRET'] },
9969
);
10070
}
10171

0 commit comments

Comments
 (0)