bazaar.it 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +485 -3
- package/bin/baz.js +6 -1
- package/dist/commands/auth.d.ts +2 -0
- package/dist/commands/auth.js +109 -0
- package/dist/commands/capabilities.d.ts +2 -0
- package/dist/commands/capabilities.js +44 -0
- package/dist/commands/context.d.ts +13 -0
- package/dist/commands/context.js +498 -0
- package/dist/commands/export.d.ts +2 -0
- package/dist/commands/export.js +360 -0
- package/dist/commands/logs.d.ts +2 -0
- package/dist/commands/logs.js +180 -0
- package/dist/commands/loop.d.ts +2 -0
- package/dist/commands/loop.js +538 -0
- package/dist/commands/mcp.d.ts +2 -0
- package/dist/commands/mcp.js +143 -0
- package/dist/commands/media.d.ts +2 -0
- package/dist/commands/media.js +362 -0
- package/dist/commands/project.d.ts +2 -0
- package/dist/commands/project.js +786 -0
- package/dist/commands/prompt.d.ts +2 -0
- package/dist/commands/prompt.js +540 -0
- package/dist/commands/recipe.d.ts +15 -0
- package/dist/commands/recipe.js +607 -0
- package/dist/commands/review.d.ts +17 -0
- package/dist/commands/review.js +345 -0
- package/dist/commands/scenes.d.ts +2 -0
- package/dist/commands/scenes.js +481 -0
- package/dist/commands/share.d.ts +2 -0
- package/dist/commands/share.js +226 -0
- package/dist/commands/state.d.ts +2 -0
- package/dist/commands/state.js +171 -0
- package/dist/commands/status.d.ts +2 -0
- package/dist/commands/status.js +219 -0
- package/dist/commands/template.d.ts +2 -0
- package/dist/commands/template.js +123 -0
- package/dist/commands/verify.d.ts +2 -0
- package/dist/commands/verify.js +150 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +124 -0
- package/dist/lib/api.d.ts +188 -0
- package/dist/lib/api.js +719 -0
- package/dist/lib/banner.d.ts +12 -0
- package/dist/lib/banner.js +69 -0
- package/dist/lib/config.d.ts +33 -0
- package/dist/lib/config.js +99 -0
- package/dist/lib/output.d.ts +52 -0
- package/dist/lib/output.js +162 -0
- package/dist/lib/project-state.d.ts +52 -0
- package/dist/lib/project-state.js +178 -0
- package/dist/lib/sse.d.ts +168 -0
- package/dist/lib/sse.js +227 -0
- package/dist/lib/version.d.ts +1 -0
- package/dist/lib/version.js +3 -0
- package/dist/repl.d.ts +4 -0
- package/dist/repl.js +764 -0
- package/package.json +32 -5
package/dist/lib/api.js
ADDED
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
import { parseSSEEvent } from './sse.js';
|
|
2
|
+
/**
|
|
3
|
+
* Error codes for better CLI error handling
|
|
4
|
+
* These map to semantic exit codes for agent consumption
|
|
5
|
+
*/
|
|
6
|
+
export const ErrorCodes = {
|
|
7
|
+
// Auth errors (exit code 13)
|
|
8
|
+
AUTH_MISSING: 'AUTH_MISSING',
|
|
9
|
+
AUTH_INVALID: 'AUTH_INVALID',
|
|
10
|
+
AUTH_EXPIRED: 'AUTH_EXPIRED',
|
|
11
|
+
FORBIDDEN: 'FORBIDDEN',
|
|
12
|
+
// Input/validation errors (exit code 64-66)
|
|
13
|
+
NOT_FOUND: 'NOT_FOUND',
|
|
14
|
+
VALIDATION: 'VALIDATION',
|
|
15
|
+
// Transient errors (exit code 10)
|
|
16
|
+
RATE_LIMIT: 'RATE_LIMIT',
|
|
17
|
+
// Resource errors (exit code 11)
|
|
18
|
+
BALANCE: 'BALANCE',
|
|
19
|
+
CAPACITY: 'CAPACITY',
|
|
20
|
+
// Semantic errors (exit code 12)
|
|
21
|
+
CONTENT_FILTER: 'CONTENT_FILTER',
|
|
22
|
+
// Server/network errors (exit code 10 - retryable)
|
|
23
|
+
SERVER: 'SERVER',
|
|
24
|
+
NETWORK: 'NETWORK',
|
|
25
|
+
TIMEOUT: 'TIMEOUT',
|
|
26
|
+
// Fatal (exit code 1)
|
|
27
|
+
UNKNOWN: 'UNKNOWN',
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Map error codes to categories
|
|
31
|
+
*/
|
|
32
|
+
export function getErrorCategory(code) {
|
|
33
|
+
switch (code) {
|
|
34
|
+
case ErrorCodes.AUTH_MISSING:
|
|
35
|
+
case ErrorCodes.AUTH_INVALID:
|
|
36
|
+
case ErrorCodes.AUTH_EXPIRED:
|
|
37
|
+
case ErrorCodes.FORBIDDEN:
|
|
38
|
+
return 'auth';
|
|
39
|
+
case ErrorCodes.NOT_FOUND:
|
|
40
|
+
case ErrorCodes.VALIDATION:
|
|
41
|
+
return 'validation';
|
|
42
|
+
case ErrorCodes.RATE_LIMIT:
|
|
43
|
+
case ErrorCodes.SERVER:
|
|
44
|
+
case ErrorCodes.NETWORK:
|
|
45
|
+
case ErrorCodes.TIMEOUT:
|
|
46
|
+
return 'transient';
|
|
47
|
+
case ErrorCodes.BALANCE:
|
|
48
|
+
case ErrorCodes.CAPACITY:
|
|
49
|
+
return 'resource';
|
|
50
|
+
case ErrorCodes.CONTENT_FILTER:
|
|
51
|
+
return 'semantic';
|
|
52
|
+
default:
|
|
53
|
+
return 'fatal';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Map error codes to exit codes for agent consumption
|
|
58
|
+
* Based on research: 0=success, 10=transient, 11=capacity, 12=semantic, 13=auth, 64-66=validation, 1=fatal
|
|
59
|
+
*/
|
|
60
|
+
export function getExitCode(code) {
|
|
61
|
+
switch (code) {
|
|
62
|
+
case ErrorCodes.RATE_LIMIT:
|
|
63
|
+
case ErrorCodes.SERVER:
|
|
64
|
+
case ErrorCodes.NETWORK:
|
|
65
|
+
case ErrorCodes.TIMEOUT:
|
|
66
|
+
return 10; // Transient - retry with backoff
|
|
67
|
+
case ErrorCodes.BALANCE:
|
|
68
|
+
case ErrorCodes.CAPACITY:
|
|
69
|
+
return 11; // Resource exhaustion
|
|
70
|
+
case ErrorCodes.CONTENT_FILTER:
|
|
71
|
+
return 12; // Semantic - rephrase prompt
|
|
72
|
+
case ErrorCodes.AUTH_MISSING:
|
|
73
|
+
case ErrorCodes.AUTH_INVALID:
|
|
74
|
+
case ErrorCodes.AUTH_EXPIRED:
|
|
75
|
+
case ErrorCodes.FORBIDDEN:
|
|
76
|
+
return 13; // Auth - escalate to human
|
|
77
|
+
case ErrorCodes.NOT_FOUND:
|
|
78
|
+
return 64; // Input error
|
|
79
|
+
case ErrorCodes.VALIDATION:
|
|
80
|
+
return 65; // Validation error
|
|
81
|
+
default:
|
|
82
|
+
return 1; // Fatal
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Check if error is retryable
|
|
87
|
+
*/
|
|
88
|
+
export function isRetryable(code) {
|
|
89
|
+
const category = getErrorCategory(code);
|
|
90
|
+
return category === 'transient' || category === 'resource';
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Check if error is transient (temporary)
|
|
94
|
+
*/
|
|
95
|
+
export function isTransient(code) {
|
|
96
|
+
return getErrorCategory(code) === 'transient';
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Custom API error with agent-friendly metadata
|
|
100
|
+
* Includes: code, category, retryable, transient, suggestion, retryAfter
|
|
101
|
+
*/
|
|
102
|
+
export class ApiError extends Error {
|
|
103
|
+
code;
|
|
104
|
+
category;
|
|
105
|
+
retryable;
|
|
106
|
+
transient;
|
|
107
|
+
exitCode;
|
|
108
|
+
statusCode;
|
|
109
|
+
details;
|
|
110
|
+
suggestion;
|
|
111
|
+
retryAfter; // seconds to wait before retry
|
|
112
|
+
constructor(message, code, options) {
|
|
113
|
+
super(message);
|
|
114
|
+
this.name = 'ApiError';
|
|
115
|
+
this.code = code;
|
|
116
|
+
this.category = getErrorCategory(code);
|
|
117
|
+
this.retryable = isRetryable(code);
|
|
118
|
+
this.transient = isTransient(code);
|
|
119
|
+
this.exitCode = getExitCode(code);
|
|
120
|
+
this.statusCode = options?.statusCode;
|
|
121
|
+
this.details = options?.details;
|
|
122
|
+
this.suggestion = options?.suggestion;
|
|
123
|
+
this.retryAfter = options?.retryAfter;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Convert to JSON for agent consumption
|
|
127
|
+
*/
|
|
128
|
+
toJSON() {
|
|
129
|
+
return {
|
|
130
|
+
type: 'error',
|
|
131
|
+
code: this.code,
|
|
132
|
+
errorType: this.code, // Alias for compatibility
|
|
133
|
+
message: this.message,
|
|
134
|
+
category: this.category,
|
|
135
|
+
retryable: this.retryable,
|
|
136
|
+
transient: this.transient,
|
|
137
|
+
exitCode: this.exitCode,
|
|
138
|
+
...(this.retryAfter !== undefined && { retryAfter: this.retryAfter }),
|
|
139
|
+
...(this.suggestion && { suggestion: this.suggestion }),
|
|
140
|
+
...(this.details && { details: this.details }),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Parse API error response into agent-friendly error
|
|
146
|
+
*/
|
|
147
|
+
function parseApiError(status, statusText, body, headers) {
|
|
148
|
+
let message = `API error: ${status} ${statusText}`;
|
|
149
|
+
let details;
|
|
150
|
+
let code = ErrorCodes.UNKNOWN;
|
|
151
|
+
let suggestion;
|
|
152
|
+
let retryAfter;
|
|
153
|
+
// Parse Retry-After header if present
|
|
154
|
+
if (headers) {
|
|
155
|
+
const retryHeader = headers.get('Retry-After');
|
|
156
|
+
if (retryHeader) {
|
|
157
|
+
const parsed = parseInt(retryHeader, 10);
|
|
158
|
+
if (!isNaN(parsed)) {
|
|
159
|
+
retryAfter = parsed;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
// Try to parse JSON error body
|
|
164
|
+
try {
|
|
165
|
+
const json = JSON.parse(body);
|
|
166
|
+
if (json.error?.message) {
|
|
167
|
+
message = json.error.message;
|
|
168
|
+
}
|
|
169
|
+
else if (json.message) {
|
|
170
|
+
message = json.message;
|
|
171
|
+
}
|
|
172
|
+
if (json.error?.details || json.details) {
|
|
173
|
+
details = json.error?.details || json.details;
|
|
174
|
+
}
|
|
175
|
+
// Check for retry_after in response body
|
|
176
|
+
if (json.retry_after || json.retryAfter) {
|
|
177
|
+
retryAfter = json.retry_after || json.retryAfter;
|
|
178
|
+
}
|
|
179
|
+
// Check for specific error codes from server
|
|
180
|
+
if (json.error?.code || json.code) {
|
|
181
|
+
const serverCode = json.error?.code || json.code;
|
|
182
|
+
if (serverCode === 'INSUFFICIENT_BALANCE') {
|
|
183
|
+
code = ErrorCodes.BALANCE;
|
|
184
|
+
suggestion = 'Top up your balance at https://bazaar.it/settings/billing';
|
|
185
|
+
}
|
|
186
|
+
else if (serverCode === 'CONTENT_POLICY_VIOLATION' || serverCode === 'SAFETY_FILTER') {
|
|
187
|
+
code = ErrorCodes.CONTENT_FILTER;
|
|
188
|
+
suggestion = 'Rephrase your prompt to avoid restricted content';
|
|
189
|
+
}
|
|
190
|
+
else if (serverCode === 'CAPACITY_EXCEEDED' || serverCode === 'GPU_UNAVAILABLE') {
|
|
191
|
+
code = ErrorCodes.CAPACITY;
|
|
192
|
+
suggestion = 'GPU resources busy. Try again in a few minutes';
|
|
193
|
+
retryAfter = retryAfter || 60;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
if (body && body.length < 200) {
|
|
199
|
+
details = body;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// Map HTTP status to error code and suggestions
|
|
203
|
+
switch (status) {
|
|
204
|
+
case 401:
|
|
205
|
+
code = ErrorCodes.AUTH_INVALID;
|
|
206
|
+
message = 'Authentication failed';
|
|
207
|
+
suggestion = 'Check your API key or run: baz auth login <api-key>';
|
|
208
|
+
break;
|
|
209
|
+
case 403:
|
|
210
|
+
code = ErrorCodes.FORBIDDEN;
|
|
211
|
+
message = 'Access denied';
|
|
212
|
+
suggestion = 'Your API key may not have permission for this operation';
|
|
213
|
+
break;
|
|
214
|
+
case 404:
|
|
215
|
+
code = ErrorCodes.NOT_FOUND;
|
|
216
|
+
message = 'Resource not found';
|
|
217
|
+
suggestion = 'The project or resource may have been deleted';
|
|
218
|
+
break;
|
|
219
|
+
case 400:
|
|
220
|
+
code = ErrorCodes.VALIDATION;
|
|
221
|
+
// Keep the parsed message for validation errors
|
|
222
|
+
break;
|
|
223
|
+
case 402:
|
|
224
|
+
code = ErrorCodes.BALANCE;
|
|
225
|
+
message = 'Insufficient balance';
|
|
226
|
+
suggestion = 'Top up your balance at https://bazaar.it/settings/billing';
|
|
227
|
+
break;
|
|
228
|
+
case 429:
|
|
229
|
+
code = ErrorCodes.RATE_LIMIT;
|
|
230
|
+
message = 'Rate limit exceeded';
|
|
231
|
+
suggestion = retryAfter
|
|
232
|
+
? `Wait ${retryAfter} seconds and try again`
|
|
233
|
+
: 'Wait a moment and try again';
|
|
234
|
+
retryAfter = retryAfter || 30; // Default to 30s for rate limits
|
|
235
|
+
break;
|
|
236
|
+
case 408:
|
|
237
|
+
code = ErrorCodes.TIMEOUT;
|
|
238
|
+
message = 'Request timed out';
|
|
239
|
+
suggestion = 'Try again or reduce request complexity';
|
|
240
|
+
retryAfter = 5;
|
|
241
|
+
break;
|
|
242
|
+
case 500:
|
|
243
|
+
case 502:
|
|
244
|
+
case 503:
|
|
245
|
+
case 504:
|
|
246
|
+
code = ErrorCodes.SERVER;
|
|
247
|
+
message = 'Server error';
|
|
248
|
+
suggestion = 'Try again in a few minutes';
|
|
249
|
+
retryAfter = retryAfter || 60;
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
return new ApiError(message, code, { statusCode: status, details, suggestion, retryAfter });
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Make a tRPC-style API request
|
|
256
|
+
*
|
|
257
|
+
* Since we're calling the server directly without the tRPC client,
|
|
258
|
+
* we use the HTTP batch format.
|
|
259
|
+
*/
|
|
260
|
+
export async function apiRequest(config, procedure, input) {
|
|
261
|
+
if (!config.apiKey) {
|
|
262
|
+
throw new ApiError('No API key configured', ErrorCodes.AUTH_MISSING, { suggestion: 'Run: baz auth login <api-key>' });
|
|
263
|
+
}
|
|
264
|
+
// Explicit allowlist of tRPC query procedures (GET requests).
|
|
265
|
+
// Everything else is treated as a mutation (POST).
|
|
266
|
+
// Source of truth: grep for `.query(` in src/server/api/routers/
|
|
267
|
+
const QUERY_PROCEDURES = new Set([
|
|
268
|
+
// billing
|
|
269
|
+
'billing.getAll', 'billing.getGenerationModels', 'billing.getPackages',
|
|
270
|
+
'billing.getMonthlyPackages', 'billing.get', 'billing.getSummary',
|
|
271
|
+
'billing.canExportWatermarkFree', 'billing.getMargins',
|
|
272
|
+
'billing.getAllOperations', 'billing.getAllPackages',
|
|
273
|
+
'billing.getModelMapping', 'billing.getEconomicsSummary',
|
|
274
|
+
// project
|
|
275
|
+
'project.getById', 'project.validate', 'project.verify',
|
|
276
|
+
'project.getFullProject', 'project.list', 'project.getLatestId',
|
|
277
|
+
'project.listScenePlans', 'project.getUploads', 'project.getUserUploads',
|
|
278
|
+
// chat
|
|
279
|
+
'chat.getMessages',
|
|
280
|
+
// payment
|
|
281
|
+
'payment.getSubscriptionSummary',
|
|
282
|
+
// api-key
|
|
283
|
+
'apiKey.list', 'apiKey.get',
|
|
284
|
+
// notifications
|
|
285
|
+
'productUpdates.getUnseen', 'creditNotifications.getUnseen',
|
|
286
|
+
// integrations
|
|
287
|
+
'github.getConnection', 'github.listRepos',
|
|
288
|
+
'figma.getConnectionStatus', 'figma.checkConnection', 'figma.getOAuthUrl',
|
|
289
|
+
// team
|
|
290
|
+
'team.getMyTeams', 'team.getMyPendingInvites', 'team.getMyPendingInvitations',
|
|
291
|
+
// templates & storyboard
|
|
292
|
+
'templates.getCategories', 'templates.getUserTemplates',
|
|
293
|
+
'templates.getAll', 'templates.getManyByIds',
|
|
294
|
+
'storyboard.listTemplates',
|
|
295
|
+
// brand
|
|
296
|
+
'brandExtraction.listByUser',
|
|
297
|
+
// share
|
|
298
|
+
'share.getProjectShare', 'share.getMyShares',
|
|
299
|
+
// media
|
|
300
|
+
'media.getMediaContext', 'media.getUserMediaDefaults',
|
|
301
|
+
// context
|
|
302
|
+
'context.list',
|
|
303
|
+
// recipe
|
|
304
|
+
'recipe.getByProject', 'recipe.getGeneratedAssets',
|
|
305
|
+
// render
|
|
306
|
+
'render.listRenders', 'render.getRenderStatus',
|
|
307
|
+
// usage
|
|
308
|
+
'usage.getPromptUsage',
|
|
309
|
+
]);
|
|
310
|
+
const isQuery = QUERY_PROCEDURES.has(procedure);
|
|
311
|
+
// Build URL with input for GET requests (tRPC format)
|
|
312
|
+
// tRPC v11 expects input wrapped in {"json": ...} format
|
|
313
|
+
let url = `${config.apiUrl}/api/trpc/${procedure}`;
|
|
314
|
+
if (isQuery && input) {
|
|
315
|
+
const wrappedInput = { json: input };
|
|
316
|
+
const encodedInput = encodeURIComponent(JSON.stringify(wrappedInput));
|
|
317
|
+
url += `?input=${encodedInput}`;
|
|
318
|
+
}
|
|
319
|
+
let response;
|
|
320
|
+
try {
|
|
321
|
+
response = await fetch(url, {
|
|
322
|
+
method: isQuery ? 'GET' : 'POST',
|
|
323
|
+
headers: {
|
|
324
|
+
'Content-Type': 'application/json',
|
|
325
|
+
'x-api-key': config.apiKey,
|
|
326
|
+
},
|
|
327
|
+
body: isQuery ? undefined : JSON.stringify({ json: input }),
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
catch (err) {
|
|
331
|
+
throw new ApiError('Network error: Unable to connect to server', ErrorCodes.NETWORK, { details: err.message, suggestion: 'Check your internet connection' });
|
|
332
|
+
}
|
|
333
|
+
if (!response.ok) {
|
|
334
|
+
const text = await response.text();
|
|
335
|
+
throw parseApiError(response.status, response.statusText, text, response.headers);
|
|
336
|
+
}
|
|
337
|
+
const json = await response.json();
|
|
338
|
+
// tRPC returns nested format: { result: { data: { json: ... } } }
|
|
339
|
+
// Or for mutations: { result: { data: ... } }
|
|
340
|
+
if (json.result?.data?.json !== undefined) {
|
|
341
|
+
return json.result.data.json;
|
|
342
|
+
}
|
|
343
|
+
if (json.result?.data) {
|
|
344
|
+
return json.result.data;
|
|
345
|
+
}
|
|
346
|
+
if (json.json !== undefined) {
|
|
347
|
+
return json.json;
|
|
348
|
+
}
|
|
349
|
+
return json;
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Upload an image and return the URL (backward compatible)
|
|
353
|
+
*/
|
|
354
|
+
export async function uploadImage(config, options) {
|
|
355
|
+
const result = await uploadMedia(config, {
|
|
356
|
+
projectId: options.projectId,
|
|
357
|
+
buffer: options.buffer,
|
|
358
|
+
filename: options.filename,
|
|
359
|
+
mimeType: `image/${getFileExtension(options.filename)}`,
|
|
360
|
+
});
|
|
361
|
+
return result.url;
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Upload any media file (image, video, audio) and return full result
|
|
365
|
+
*/
|
|
366
|
+
export async function uploadMedia(config, options) {
|
|
367
|
+
if (!config.apiKey) {
|
|
368
|
+
throw new ApiError('No API key configured', ErrorCodes.AUTH_MISSING, { suggestion: 'Run: baz auth login <api-key>' });
|
|
369
|
+
}
|
|
370
|
+
// Create FormData with file
|
|
371
|
+
// Convert Buffer to Uint8Array for proper Blob compatibility
|
|
372
|
+
const formData = new FormData();
|
|
373
|
+
const uint8Array = new Uint8Array(options.buffer);
|
|
374
|
+
const blob = new Blob([uint8Array], { type: options.mimeType });
|
|
375
|
+
formData.append('file', blob, options.filename);
|
|
376
|
+
formData.append('projectId', options.projectId);
|
|
377
|
+
let response;
|
|
378
|
+
try {
|
|
379
|
+
response = await fetch(`${config.apiUrl}/api/upload`, {
|
|
380
|
+
method: 'POST',
|
|
381
|
+
headers: {
|
|
382
|
+
'x-api-key': config.apiKey,
|
|
383
|
+
},
|
|
384
|
+
body: formData,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
catch (err) {
|
|
388
|
+
throw new ApiError('Network error during upload', ErrorCodes.NETWORK, { details: err.message, suggestion: 'Check your internet connection' });
|
|
389
|
+
}
|
|
390
|
+
if (!response.ok) {
|
|
391
|
+
const text = await response.text();
|
|
392
|
+
throw parseApiError(response.status, response.statusText, text, response.headers);
|
|
393
|
+
}
|
|
394
|
+
const result = await response.json();
|
|
395
|
+
return result;
|
|
396
|
+
}
|
|
397
|
+
function getFileExtension(filename) {
|
|
398
|
+
const ext = filename.split('.').pop()?.toLowerCase();
|
|
399
|
+
return ext || 'png';
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Validate URL is safe to fetch (basic SSRF protection)
|
|
403
|
+
*/
|
|
404
|
+
function isUrlSafe(urlString) {
|
|
405
|
+
try {
|
|
406
|
+
const parsed = new URL(urlString);
|
|
407
|
+
// Only allow http/https
|
|
408
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
// Block private/internal IP ranges
|
|
412
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
413
|
+
const blockedPatterns = [
|
|
414
|
+
'localhost',
|
|
415
|
+
'127.0.0.1',
|
|
416
|
+
'0.0.0.0',
|
|
417
|
+
'10.',
|
|
418
|
+
'172.16.', '172.17.', '172.18.', '172.19.',
|
|
419
|
+
'172.20.', '172.21.', '172.22.', '172.23.',
|
|
420
|
+
'172.24.', '172.25.', '172.26.', '172.27.',
|
|
421
|
+
'172.28.', '172.29.', '172.30.', '172.31.',
|
|
422
|
+
'192.168.',
|
|
423
|
+
'169.254.',
|
|
424
|
+
'[::1]',
|
|
425
|
+
'.local',
|
|
426
|
+
'.internal',
|
|
427
|
+
];
|
|
428
|
+
for (const pattern of blockedPatterns) {
|
|
429
|
+
if (hostname.includes(pattern) || hostname.startsWith(pattern)) {
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return true;
|
|
434
|
+
}
|
|
435
|
+
catch {
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Fetch a URL and extract content (for --url flag)
|
|
441
|
+
*/
|
|
442
|
+
export async function fetchUrlContent(url) {
|
|
443
|
+
// Security: validate URL before fetching
|
|
444
|
+
if (!isUrlSafe(url)) {
|
|
445
|
+
throw new ApiError('URL not allowed', ErrorCodes.VALIDATION, { details: 'Only public HTTP/HTTPS URLs are allowed' });
|
|
446
|
+
}
|
|
447
|
+
let response;
|
|
448
|
+
try {
|
|
449
|
+
// Create abort controller for timeout
|
|
450
|
+
const controller = new AbortController();
|
|
451
|
+
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30s timeout
|
|
452
|
+
response = await fetch(url, {
|
|
453
|
+
headers: {
|
|
454
|
+
'User-Agent': 'BazaarCLI/1.0 (https://bazaar.it)',
|
|
455
|
+
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
456
|
+
},
|
|
457
|
+
signal: controller.signal,
|
|
458
|
+
redirect: 'follow', // Follow redirects (default max 20)
|
|
459
|
+
});
|
|
460
|
+
clearTimeout(timeoutId);
|
|
461
|
+
}
|
|
462
|
+
catch (err) {
|
|
463
|
+
const errorMsg = err.name === 'AbortError'
|
|
464
|
+
? 'Request timed out (30s limit)'
|
|
465
|
+
: err.message;
|
|
466
|
+
throw new ApiError(`Failed to fetch URL: ${url}`, ErrorCodes.NETWORK, { details: errorMsg });
|
|
467
|
+
}
|
|
468
|
+
if (!response.ok) {
|
|
469
|
+
throw new ApiError(`URL returned error: ${response.status} ${response.statusText}`, ErrorCodes.UNKNOWN, { details: `Failed to fetch: ${url}` });
|
|
470
|
+
}
|
|
471
|
+
const contentType = response.headers.get('content-type') || '';
|
|
472
|
+
// Limit content size (1MB max)
|
|
473
|
+
const contentLength = response.headers.get('content-length');
|
|
474
|
+
if (contentLength && parseInt(contentLength, 10) > 1024 * 1024) {
|
|
475
|
+
throw new ApiError('URL content too large', ErrorCodes.VALIDATION, { details: 'Maximum content size is 1MB' });
|
|
476
|
+
}
|
|
477
|
+
const text = await response.text();
|
|
478
|
+
// Determine content type
|
|
479
|
+
let type = 'unknown';
|
|
480
|
+
if (contentType.includes('text/html')) {
|
|
481
|
+
type = 'html';
|
|
482
|
+
}
|
|
483
|
+
else if (contentType.includes('application/json')) {
|
|
484
|
+
type = 'json';
|
|
485
|
+
}
|
|
486
|
+
else if (contentType.includes('text/')) {
|
|
487
|
+
type = 'text';
|
|
488
|
+
}
|
|
489
|
+
// Try to extract title from HTML
|
|
490
|
+
let title;
|
|
491
|
+
if (type === 'html') {
|
|
492
|
+
const titleMatch = text.match(/<title[^>]*>([^<]+)<\/title>/i);
|
|
493
|
+
if (titleMatch) {
|
|
494
|
+
title = titleMatch[1].trim();
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
return { content: text, title, type };
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Stream a generation request (SSE)
|
|
501
|
+
*/
|
|
502
|
+
export async function streamGeneration(config, options) {
|
|
503
|
+
if (!config.apiKey) {
|
|
504
|
+
throw new Error('No API key configured. Run: baz auth login');
|
|
505
|
+
}
|
|
506
|
+
const mode = options.mode || 'agent';
|
|
507
|
+
const agentMode = mode === 'agent' || mode === 'agent-max';
|
|
508
|
+
const response = await fetch(`${config.apiUrl}/api/generate-stream`, {
|
|
509
|
+
method: 'POST',
|
|
510
|
+
headers: {
|
|
511
|
+
'Content-Type': 'application/json',
|
|
512
|
+
'x-api-key': config.apiKey,
|
|
513
|
+
},
|
|
514
|
+
body: JSON.stringify({
|
|
515
|
+
message: options.prompt, // Server expects 'message', not 'prompt'
|
|
516
|
+
projectId: options.projectId,
|
|
517
|
+
mode,
|
|
518
|
+
agentMode,
|
|
519
|
+
imageUrls: options.imageUrls,
|
|
520
|
+
// Handshake protocol fields
|
|
521
|
+
planOnly: options.planOnly,
|
|
522
|
+
requirements: options.requirements,
|
|
523
|
+
// Budget control
|
|
524
|
+
...(options.budgetUsd != null && options.budgetUsd > 0 ? { budgetUsd: options.budgetUsd } : {}),
|
|
525
|
+
// Model — CLI defaults to Sonnet to keep costs reasonable
|
|
526
|
+
modelOverride: options.modelOverride || 'claude-sonnet-4-5',
|
|
527
|
+
}),
|
|
528
|
+
});
|
|
529
|
+
if (!response.ok) {
|
|
530
|
+
const text = await response.text();
|
|
531
|
+
throw new Error(`Stream error: ${response.status} - ${text}`);
|
|
532
|
+
}
|
|
533
|
+
const reader = response.body?.getReader();
|
|
534
|
+
if (!reader) {
|
|
535
|
+
throw new Error('No response body');
|
|
536
|
+
}
|
|
537
|
+
const decoder = new TextDecoder();
|
|
538
|
+
let pendingChunk = '';
|
|
539
|
+
let result = {
|
|
540
|
+
success: false,
|
|
541
|
+
scenesCreated: [],
|
|
542
|
+
scenesUpdated: [],
|
|
543
|
+
errors: [],
|
|
544
|
+
continue: true, // Will be set to false when stream ends
|
|
545
|
+
};
|
|
546
|
+
const processSSELine = (rawLine) => {
|
|
547
|
+
const line = rawLine.trim();
|
|
548
|
+
if (!line.startsWith('data:'))
|
|
549
|
+
return;
|
|
550
|
+
const dataStr = line.slice(5).trim();
|
|
551
|
+
if (!dataStr || dataStr === '[DONE]')
|
|
552
|
+
return;
|
|
553
|
+
try {
|
|
554
|
+
const data = JSON.parse(dataStr);
|
|
555
|
+
const event = parseStreamEvent(data);
|
|
556
|
+
// Add timestamp and raw data for bot/debug mode
|
|
557
|
+
if (options.includeRaw) {
|
|
558
|
+
event.timestamp = new Date().toISOString();
|
|
559
|
+
event.raw = data;
|
|
560
|
+
}
|
|
561
|
+
if (options.onEvent) {
|
|
562
|
+
options.onEvent(event);
|
|
563
|
+
}
|
|
564
|
+
// Accumulate results
|
|
565
|
+
if (event.type === 'scene_created') {
|
|
566
|
+
result.scenesCreated.push(event.sceneId);
|
|
567
|
+
}
|
|
568
|
+
if (event.type === 'scene_updated') {
|
|
569
|
+
result.scenesUpdated.push(event.sceneId);
|
|
570
|
+
}
|
|
571
|
+
if (event.type === 'error') {
|
|
572
|
+
result.errors.push(event.message);
|
|
573
|
+
}
|
|
574
|
+
if (event.type === 'complete') {
|
|
575
|
+
result.success = true;
|
|
576
|
+
result.summary = event.summary;
|
|
577
|
+
result.stopReason = event.stopReason;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
catch {
|
|
581
|
+
// Ignore parse errors for malformed event payloads
|
|
582
|
+
}
|
|
583
|
+
};
|
|
584
|
+
try {
|
|
585
|
+
while (true) {
|
|
586
|
+
const { done, value } = await reader.read();
|
|
587
|
+
if (done)
|
|
588
|
+
break;
|
|
589
|
+
pendingChunk += decoder.decode(value, { stream: true });
|
|
590
|
+
const lines = pendingChunk.split('\n');
|
|
591
|
+
pendingChunk = lines.pop() || '';
|
|
592
|
+
for (const line of lines) {
|
|
593
|
+
processSSELine(line);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
// Process any trailing buffered line if stream ended without newline.
|
|
597
|
+
if (pendingChunk.trim().length > 0) {
|
|
598
|
+
processSSELine(pendingChunk);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
finally {
|
|
602
|
+
reader.releaseLock();
|
|
603
|
+
}
|
|
604
|
+
// Stream ended - set continue to false
|
|
605
|
+
result.continue = false;
|
|
606
|
+
return result;
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Convert a normalized SSE event into the CLI's StreamEvent.
|
|
610
|
+
* This is a thin mapping layer that preserves backwards compatibility for CLI consumers.
|
|
611
|
+
*
|
|
612
|
+
* The `rawData` parameter carries the original SSE payload before the shared parser
|
|
613
|
+
* normalizes it. We need this because the shared parser intentionally strips fields
|
|
614
|
+
* like `budgetUsd` on ready events (the web UI doesn't need them), but the CLI does.
|
|
615
|
+
*/
|
|
616
|
+
function normalizedToStreamEvent(event, rawData) {
|
|
617
|
+
const raw = rawData;
|
|
618
|
+
switch (event.type) {
|
|
619
|
+
case 'agent_thinking':
|
|
620
|
+
return { type: 'thinking', message: event.content };
|
|
621
|
+
case 'agent_action': {
|
|
622
|
+
// Map action string to start/complete phase for --events-only filtering
|
|
623
|
+
const actionStr = event.action || '';
|
|
624
|
+
const phase = actionStr.includes('start') || actionStr === 'tool_use' ? 'start' : 'complete';
|
|
625
|
+
return {
|
|
626
|
+
type: 'tool_use',
|
|
627
|
+
tool: event.toolName,
|
|
628
|
+
message: event.message,
|
|
629
|
+
action: phase,
|
|
630
|
+
sceneId: event.sceneId,
|
|
631
|
+
sceneName: event.sceneName,
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
case 'agent_tool_input':
|
|
635
|
+
return { type: 'text', message: '' }; // CLI doesn't display tool input streaming
|
|
636
|
+
case 'error':
|
|
637
|
+
return {
|
|
638
|
+
type: 'error',
|
|
639
|
+
message: event.error,
|
|
640
|
+
continue: true, // Error doesn't always mean stop - agent decides
|
|
641
|
+
};
|
|
642
|
+
case 'scene_added':
|
|
643
|
+
return {
|
|
644
|
+
type: 'scene_created',
|
|
645
|
+
sceneId: event.sceneId,
|
|
646
|
+
sceneName: event.sceneName,
|
|
647
|
+
};
|
|
648
|
+
case 'scene_updated':
|
|
649
|
+
return {
|
|
650
|
+
type: 'scene_updated',
|
|
651
|
+
sceneId: event.sceneId,
|
|
652
|
+
sceneName: event.sceneName,
|
|
653
|
+
};
|
|
654
|
+
case 'generation_complete': {
|
|
655
|
+
const budget = event.budget;
|
|
656
|
+
return {
|
|
657
|
+
type: 'complete',
|
|
658
|
+
summary: event.summary,
|
|
659
|
+
stopReason: event.stopReason,
|
|
660
|
+
continue: false,
|
|
661
|
+
...(budget && {
|
|
662
|
+
budget: {
|
|
663
|
+
budgetUsd: budget.budgetUsd,
|
|
664
|
+
totalBilledUsd: budget.totalBilledUsd,
|
|
665
|
+
remainingUsd: budget.remainingUsd,
|
|
666
|
+
},
|
|
667
|
+
}),
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
case 'recipe_message':
|
|
671
|
+
return {
|
|
672
|
+
type: 'recipe_created',
|
|
673
|
+
recipeName: event.recipe?.title,
|
|
674
|
+
recipeDescription: event.description,
|
|
675
|
+
message: event.description || `Created recipe: ${event.recipe?.title || 'Untitled'}`,
|
|
676
|
+
};
|
|
677
|
+
case 'workflow_message':
|
|
678
|
+
return {
|
|
679
|
+
type: 'workflow_created',
|
|
680
|
+
workflowName: event.workflowName,
|
|
681
|
+
workflowDescription: event.description,
|
|
682
|
+
message: event.description || `Created workflow: ${event.workflowName || 'Untitled'}`,
|
|
683
|
+
};
|
|
684
|
+
case 'assistant_message_chunk':
|
|
685
|
+
return { type: 'text', message: event.message };
|
|
686
|
+
case 'ready':
|
|
687
|
+
// Emit structured ready event — budgetUsd is on raw data (shared parser strips it)
|
|
688
|
+
return {
|
|
689
|
+
type: 'ready',
|
|
690
|
+
budgetUsd: typeof raw?.budgetUsd === 'number' ? raw.budgetUsd : undefined,
|
|
691
|
+
model: event.modelOverride || undefined,
|
|
692
|
+
};
|
|
693
|
+
case 'agent_asset': {
|
|
694
|
+
// Surface asset generation events for agent consumption
|
|
695
|
+
const asset = event.asset;
|
|
696
|
+
return {
|
|
697
|
+
type: 'asset_generated',
|
|
698
|
+
assetUrl: asset?.url,
|
|
699
|
+
assetType: asset?.type,
|
|
700
|
+
costUsd: asset?.costUsd ?? asset?.estimatedCostUsd,
|
|
701
|
+
message: asset ? `Generated ${asset.type}: ${asset.name || asset.url}` : 'Asset generated',
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
case 'title_updated':
|
|
705
|
+
case 'sidecar_token':
|
|
706
|
+
case 'sidecar_complete':
|
|
707
|
+
case 'sidecar_error':
|
|
708
|
+
case 'preference_saved':
|
|
709
|
+
case 'worker_event':
|
|
710
|
+
// Events not relevant to CLI display — emit as silent text
|
|
711
|
+
return { type: 'text', message: '' };
|
|
712
|
+
case 'unknown':
|
|
713
|
+
return { type: 'text', message: JSON.stringify(event.raw) };
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
function parseStreamEvent(data) {
|
|
717
|
+
const normalized = parseSSEEvent(data);
|
|
718
|
+
return normalizedToStreamEvent(normalized, data);
|
|
719
|
+
}
|