@lovelybunch/api 1.0.69-alpha.12 → 1.0.69-alpha.15

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.
@@ -26,7 +26,18 @@ export class JobScheduler {
26
26
  return;
27
27
  const jobs = await this.store.listJobs();
28
28
  for (const job of jobs) {
29
- await this.register(job);
29
+ // Skip corrupted jobs (jobs with _error field)
30
+ if (job._error) {
31
+ console.warn(`Skipping corrupted job ${job.id}: ${job._error}`);
32
+ continue;
33
+ }
34
+ try {
35
+ await this.register(job);
36
+ }
37
+ catch (error) {
38
+ console.error(`Failed to register job ${job.id}:`, error);
39
+ // Continue with other jobs even if one fails
40
+ }
30
41
  }
31
42
  this.initialized = true;
32
43
  }
@@ -8,6 +8,7 @@ export declare class JobStore {
8
8
  private getJobFilePath;
9
9
  listJobs(): Promise<ScheduledJob[]>;
10
10
  getJob(id: string): Promise<ScheduledJob | null>;
11
+ private createErrorJob;
11
12
  saveJob(job: ScheduledJob, bodyContent?: string): Promise<void>;
12
13
  deleteJob(id: string): Promise<boolean>;
13
14
  appendRun(jobId: string, run: ScheduledJobRun): Promise<ScheduledJob>;
@@ -90,15 +90,53 @@ export class JobStore {
90
90
  try {
91
91
  const filePath = await this.getJobFilePath(id);
92
92
  const content = await fs.readFile(filePath, 'utf-8');
93
+ // Handle empty files
94
+ if (!content || content.trim().length === 0) {
95
+ return this.createErrorJob(id, 'Job file is empty');
96
+ }
93
97
  const { data, content: body } = matter(content);
98
+ // Validate that we have at least an id field
99
+ if (!data || typeof data !== 'object' || !data.id) {
100
+ return this.createErrorJob(id, 'Job file is missing required id field');
101
+ }
94
102
  return this.fromFrontmatter(data, body);
95
103
  }
96
104
  catch (error) {
97
105
  if (error?.code === 'ENOENT')
98
106
  return null;
99
- throw error;
107
+ // Handle parsing errors (e.g., invalid YAML, corrupted frontmatter)
108
+ const errorMessage = error?.message || 'Unknown error parsing job file';
109
+ return this.createErrorJob(id, errorMessage);
100
110
  }
101
111
  }
112
+ createErrorJob(id, errorMessage) {
113
+ const now = new Date();
114
+ return {
115
+ id,
116
+ name: id,
117
+ description: undefined,
118
+ prompt: '',
119
+ model: 'anthropic/claude-sonnet-4',
120
+ status: 'paused',
121
+ schedule: {
122
+ type: 'interval',
123
+ hours: 6,
124
+ daysOfWeek: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday']
125
+ },
126
+ metadata: {
127
+ createdAt: now,
128
+ updatedAt: now,
129
+ lastRunAt: undefined,
130
+ nextRunAt: undefined,
131
+ },
132
+ runs: [],
133
+ tags: [],
134
+ contextPaths: [],
135
+ // Store error in a way that won't break serialization
136
+ // We'll use a special tag to mark error jobs
137
+ _error: errorMessage
138
+ };
139
+ }
102
140
  async saveJob(job, bodyContent = '') {
103
141
  const filePath = await this.getJobFilePath(job.id);
104
142
  const normalizedJob = {
@@ -138,34 +176,118 @@ export class JobStore {
138
176
  if (!data?.id) {
139
177
  throw new Error('Scheduled job is missing required id field');
140
178
  }
179
+ // Validate and sanitize fields to prevent issues with corrupted data
141
180
  const createdAt = toDate(data.metadata?.createdAt) ?? new Date();
142
181
  const updatedAt = toDate(data.metadata?.updatedAt) ?? createdAt;
182
+ // Validate schedule structure
183
+ let schedule;
184
+ if (data.schedule && typeof data.schedule === 'object') {
185
+ if (data.schedule.type === 'cron') {
186
+ const cronSchedule = data.schedule;
187
+ if (typeof cronSchedule.expression === 'string' && cronSchedule.expression.trim()) {
188
+ schedule = {
189
+ type: 'cron',
190
+ expression: cronSchedule.expression.trim(),
191
+ timezone: typeof cronSchedule.timezone === 'string' ? cronSchedule.timezone : undefined,
192
+ description: typeof cronSchedule.description === 'string' ? cronSchedule.description : undefined,
193
+ };
194
+ }
195
+ else {
196
+ // Invalid cron schedule, fall back to default
197
+ schedule = {
198
+ type: 'interval',
199
+ hours: 6,
200
+ daysOfWeek: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday']
201
+ };
202
+ }
203
+ }
204
+ else {
205
+ const intervalSchedule = data.schedule;
206
+ const hours = typeof intervalSchedule.hours === 'number' && intervalSchedule.hours >= 1
207
+ ? intervalSchedule.hours
208
+ : 6;
209
+ const daysOfWeek = Array.isArray(intervalSchedule.daysOfWeek)
210
+ ? intervalSchedule.daysOfWeek.filter((day) => typeof day === 'string' && ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'].includes(day.toLowerCase()))
211
+ : ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'];
212
+ schedule = {
213
+ type: 'interval',
214
+ hours,
215
+ daysOfWeek: daysOfWeek.length > 0 ? daysOfWeek : ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'],
216
+ anchorHour: typeof intervalSchedule.anchorHour === 'number' && intervalSchedule.anchorHour >= 0 && intervalSchedule.anchorHour <= 23
217
+ ? intervalSchedule.anchorHour
218
+ : undefined,
219
+ };
220
+ }
221
+ }
222
+ else {
223
+ schedule = {
224
+ type: 'interval',
225
+ hours: 6,
226
+ daysOfWeek: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday']
227
+ };
228
+ }
229
+ // Validate and sanitize runs
143
230
  const runs = Array.isArray(data.runs)
144
- ? data.runs.map((run) => ({
145
- id: run.id ?? `run-${randomUUID()}`,
146
- jobId: data.id,
147
- trigger: run.trigger,
148
- status: run.status,
149
- startedAt: toDate(run.startedAt) ?? new Date(),
150
- finishedAt: toDate(run.finishedAt),
151
- outputPath: run.outputPath,
152
- summary: run.summary,
153
- error: run.error,
154
- cliCommand: run.cliCommand,
155
- }))
231
+ ? data.runs
232
+ .filter((run) => run && typeof run === 'object')
233
+ .map((run) => {
234
+ const startedAt = toDate(run.startedAt);
235
+ if (!startedAt) {
236
+ // Skip runs with invalid dates
237
+ return null;
238
+ }
239
+ return {
240
+ id: typeof run.id === 'string' ? run.id : `run-${randomUUID()}`,
241
+ jobId: data.id,
242
+ trigger: (run.trigger === 'manual' || run.trigger === 'scheduled') ? run.trigger : 'manual',
243
+ status: (['pending', 'running', 'succeeded', 'failed'].includes(run.status)) ? run.status : 'pending',
244
+ startedAt,
245
+ finishedAt: toDate(run.finishedAt),
246
+ outputPath: typeof run.outputPath === 'string' ? run.outputPath : undefined,
247
+ summary: typeof run.summary === 'string' ? run.summary : undefined,
248
+ error: typeof run.error === 'string' ? run.error : undefined,
249
+ cliCommand: typeof run.cliCommand === 'string' ? run.cliCommand : undefined,
250
+ };
251
+ })
252
+ .filter((run) => run !== null)
253
+ : [];
254
+ // Validate and sanitize string fields
255
+ const name = typeof data.name === 'string' && data.name.trim()
256
+ ? data.name.trim().slice(0, 500) // Limit length
257
+ : data.id;
258
+ const description = typeof data.description === 'string'
259
+ ? data.description.slice(0, 1000) // Limit length
260
+ : undefined;
261
+ const prompt = typeof data.prompt === 'string'
262
+ ? data.prompt.trim() || body.trim()
263
+ : body.trim();
264
+ const model = typeof data.model === 'string' && data.model.trim()
265
+ ? data.model.trim()
266
+ : 'anthropic/claude-sonnet-4';
267
+ const status = (data.status === 'active' || data.status === 'paused')
268
+ ? data.status
269
+ : 'paused';
270
+ // Validate arrays
271
+ const tags = Array.isArray(data.tags)
272
+ ? data.tags.filter((tag) => typeof tag === 'string').slice(0, 50)
273
+ : [];
274
+ const contextPaths = Array.isArray(data.contextPaths)
275
+ ? data.contextPaths.filter((path) => typeof path === 'string').slice(0, 100)
156
276
  : [];
277
+ const agentIds = Array.isArray(data.agentIds)
278
+ ? data.agentIds.filter((id) => typeof id === 'string').slice(0, 50)
279
+ : undefined;
280
+ const mcpServers = Array.isArray(data.mcpServers)
281
+ ? data.mcpServers.filter((server) => typeof server === 'string').slice(0, 50)
282
+ : undefined;
157
283
  return {
158
284
  id: data.id,
159
- name: data.name || data.id,
160
- description: data.description,
161
- prompt: data.prompt || body.trim(),
162
- model: data.model || 'anthropic/claude-sonnet-4',
163
- status: data.status || 'paused',
164
- schedule: data.schedule ?? {
165
- type: 'interval',
166
- hours: 6,
167
- daysOfWeek: ['monday', 'tuesday', 'wednesday', 'thursday', 'friday']
168
- },
285
+ name,
286
+ description,
287
+ prompt,
288
+ model,
289
+ status,
290
+ schedule,
169
291
  metadata: {
170
292
  createdAt,
171
293
  updatedAt,
@@ -173,11 +295,11 @@ export class JobStore {
173
295
  nextRunAt: toDate(data.metadata?.nextRunAt),
174
296
  },
175
297
  runs,
176
- tags: data.tags ?? [],
177
- contextPaths: data.contextPaths ?? [],
178
- agentId: data.agentId,
179
- agentIds: data.agentIds,
180
- mcpServers: data.mcpServers,
298
+ tags,
299
+ contextPaths,
300
+ agentId: typeof data.agentId === 'string' ? data.agentId : undefined,
301
+ agentIds,
302
+ mcpServers,
181
303
  };
182
304
  }
183
305
  toFrontmatter(job) {
@@ -122,19 +122,28 @@ export class FileStorageAdapter {
122
122
  // Never allow id to be updated to prevent overwriting other proposals
123
123
  const { id: _, ...safeUpdates } = updates;
124
124
  // Handle comments separately as they're stored at root level in file format
125
- let updated = {
125
+ // Comments can come from either root level or metadata.comments
126
+ let commentsToStore = existing.metadata.comments || [];
127
+ if (safeUpdates.comments) {
128
+ // If comments are provided at root level, use them
129
+ commentsToStore = safeUpdates.comments;
130
+ }
131
+ else if (safeUpdates.metadata?.comments) {
132
+ // If comments are provided in metadata, use them
133
+ commentsToStore = safeUpdates.metadata.comments;
134
+ }
135
+ const updated = {
126
136
  ...existing,
127
137
  ...safeUpdates,
128
138
  metadata: {
129
139
  ...existing.metadata,
130
140
  ...safeUpdates.metadata,
131
- updatedAt: new Date()
132
- }
141
+ updatedAt: new Date(),
142
+ comments: commentsToStore
143
+ },
144
+ // Store comments at the root level for the file format
145
+ comments: commentsToStore
133
146
  };
134
- // If comments are being updated, store them at the root level for the file format
135
- if (safeUpdates.comments) {
136
- updated.comments = safeUpdates.comments;
137
- }
138
147
  await this.createCP(updated);
139
148
  }
140
149
  async deleteCP(id) {
@@ -1,7 +1,8 @@
1
1
  import { Hono } from 'hono';
2
- import { GET, PUT, TEST } from './route.js';
2
+ import { GET, PUT, TEST, EXPORT } from './route.js';
3
3
  const config = new Hono();
4
4
  config.get('/', GET);
5
5
  config.put('/', PUT);
6
6
  config.post('/test', TEST);
7
+ config.post('/export-to-env', EXPORT);
7
8
  export default config;
@@ -30,3 +30,14 @@ export declare function TEST(c: Context): Promise<(Response & import("hono").Typ
30
30
  success: false;
31
31
  message: string;
32
32
  }, 501, "json">)>;
33
+ export declare function EXPORT(c: Context): Promise<(Response & import("hono").TypedResponse<{
34
+ success: false;
35
+ message: string;
36
+ }, 400, "json">) | (Response & import("hono").TypedResponse<{
37
+ success: false;
38
+ message: string;
39
+ }, 500, "json">) | (Response & import("hono").TypedResponse<{
40
+ success: true;
41
+ message: string;
42
+ envPath: string;
43
+ }, import("hono/utils/http-status").ContentfulStatusCode, "json">)>;
@@ -240,3 +240,107 @@ export async function TEST(c) {
240
240
  return c.json({ success: false, message: 'Invalid request body' }, 400);
241
241
  }
242
242
  }
243
+ // Provider to environment variable mapping
244
+ const PROVIDER_ENV_VARS = {
245
+ openrouter: 'OPENROUTER_API_KEY',
246
+ anthropic: 'ANTHROPIC_API_KEY',
247
+ openai: 'OPENAI_API_KEY',
248
+ gemini: 'GEMINI_API_KEY',
249
+ factorydroid: 'FACTORY_DROID_API_KEY',
250
+ bedrock: 'AWS_BEDROCK_API_KEY',
251
+ baseten: 'BASETEN_API_KEY',
252
+ fireworks: 'FIREWORKS_API_KEY',
253
+ deepinfra: 'DEEPINFRA_API_KEY'
254
+ };
255
+ // Helper to find workspace root (where .env should be written)
256
+ async function findWorkspaceRoot() {
257
+ // Try to find .nut directory first
258
+ let currentDir = process.cwd();
259
+ while (currentDir !== path.parse(currentDir).root) {
260
+ const nutPath = path.join(currentDir, '.nut');
261
+ try {
262
+ await fs.access(nutPath);
263
+ // Found .nut directory, return parent as workspace root
264
+ return currentDir;
265
+ }
266
+ catch {
267
+ currentDir = path.dirname(currentDir);
268
+ }
269
+ }
270
+ // Fallback to cwd if no .nut found
271
+ return process.cwd();
272
+ }
273
+ // POST /api/v1/config/export-to-env
274
+ // Body: { provider: string }
275
+ // Exports the saved API key for a provider to a .env file in workspace root
276
+ export async function EXPORT(c) {
277
+ try {
278
+ const body = await c.req.json();
279
+ const provider = (body?.provider || '').toString();
280
+ if (!provider) {
281
+ return c.json({ success: false, message: 'Missing provider' }, 400);
282
+ }
283
+ const envVarName = PROVIDER_ENV_VARS[provider];
284
+ if (!envVarName) {
285
+ return c.json({ success: false, message: `Unknown provider: ${provider}` }, 400);
286
+ }
287
+ // Load the API key from global config
288
+ const configPath = await getGlobalConfigPath();
289
+ let config = { apiKeys: {}, defaults: {} };
290
+ try {
291
+ const content = await fs.readFile(configPath, 'utf-8');
292
+ config = JSON.parse(content);
293
+ }
294
+ catch {
295
+ // Config doesn't exist yet
296
+ }
297
+ const apiKey = config.apiKeys?.[provider];
298
+ if (!apiKey) {
299
+ return c.json({ success: false, message: `No API key configured for ${provider}` }, 400);
300
+ }
301
+ // Find workspace root
302
+ const workspaceRoot = await findWorkspaceRoot();
303
+ if (!workspaceRoot) {
304
+ return c.json({ success: false, message: 'Could not find workspace root' }, 500);
305
+ }
306
+ const envPath = path.join(workspaceRoot, '.env');
307
+ // Read existing .env file if it exists
308
+ let envContent = '';
309
+ try {
310
+ envContent = await fs.readFile(envPath, 'utf-8');
311
+ }
312
+ catch {
313
+ // File doesn't exist, will create new
314
+ }
315
+ // Parse existing env vars
316
+ const envLines = envContent.split('\n');
317
+ let found = false;
318
+ const updatedLines = envLines.map(line => {
319
+ const trimmed = line.trim();
320
+ if (trimmed.startsWith(envVarName + '=') || trimmed.startsWith(envVarName + ' =')) {
321
+ found = true;
322
+ return `${envVarName}=${apiKey}`;
323
+ }
324
+ return line;
325
+ });
326
+ // If not found, append to the end
327
+ if (!found) {
328
+ // Add a newline if the file doesn't end with one
329
+ if (updatedLines.length > 0 && updatedLines[updatedLines.length - 1] !== '') {
330
+ updatedLines.push('');
331
+ }
332
+ updatedLines.push(`${envVarName}=${apiKey}`);
333
+ }
334
+ // Write back to .env file
335
+ await fs.writeFile(envPath, updatedLines.join('\n'), 'utf-8');
336
+ return c.json({
337
+ success: true,
338
+ message: `${envVarName} saved to .env file`,
339
+ envPath
340
+ });
341
+ }
342
+ catch (error) {
343
+ console.error('Error exporting to env:', error);
344
+ return c.json({ success: false, message: error instanceof Error ? error.message : 'Failed to export to env' }, 500);
345
+ }
346
+ }
@@ -52,10 +52,11 @@ export async function GET(c) {
52
52
  }
53
53
  const url = new URL(c.req.url);
54
54
  const download = url.searchParams.get('download');
55
+ // Return the actual file (either for download or inline display)
56
+ const filePath = path.join(FILES_DIR, resource.path);
57
+ const fileBuffer = await fs.readFile(filePath);
55
58
  if (download === 'true') {
56
- // Return the actual file
57
- const filePath = path.join(FILES_DIR, resource.path);
58
- const fileBuffer = await fs.readFile(filePath);
59
+ // Force download with Content-Disposition: attachment
59
60
  return new Response(fileBuffer, {
60
61
  headers: {
61
62
  'Content-Type': resource.type,
@@ -64,10 +65,13 @@ export async function GET(c) {
64
65
  }
65
66
  });
66
67
  }
67
- // Return resource metadata
68
- return c.json({
69
- success: true,
70
- data: resource
68
+ // Serve file inline (for preview in iframe, img, video, etc.)
69
+ return new Response(fileBuffer, {
70
+ headers: {
71
+ 'Content-Type': resource.type,
72
+ 'Content-Length': resource.size.toString(),
73
+ 'Cache-Control': 'public, max-age=31536000'
74
+ }
71
75
  });
72
76
  }
73
77
  catch (error) {
@@ -0,0 +1,3 @@
1
+ import { Hono } from 'hono';
2
+ declare const app: Hono<import("hono/types").BlankEnv, import("hono/types").BlankSchema, "/">;
3
+ export default app;
@@ -0,0 +1,5 @@
1
+ import { Hono } from 'hono';
2
+ import { POST } from './route.js';
3
+ const app = new Hono();
4
+ app.post('/', POST);
5
+ export default app;
@@ -0,0 +1,19 @@
1
+ import { Context } from 'hono';
2
+ export declare function POST(c: Context): Promise<(Response & import("hono").TypedResponse<{
3
+ success: false;
4
+ error: {
5
+ code: string;
6
+ message: string;
7
+ };
8
+ }, 400, "json">) | (Response & import("hono").TypedResponse<{
9
+ success: true;
10
+ data: {
11
+ imageUrl: string;
12
+ };
13
+ }, import("hono/utils/http-status").ContentfulStatusCode, "json">) | (Response & import("hono").TypedResponse<{
14
+ success: false;
15
+ error: {
16
+ code: string;
17
+ message: any;
18
+ };
19
+ }, 500, "json">)>;