luxlabs 1.0.0

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.
@@ -0,0 +1,836 @@
1
+ const axios = require('axios');
2
+ const chalk = require('chalk');
3
+ const {
4
+ getApiUrl,
5
+ getStudioApiUrl,
6
+ getAuthHeaders,
7
+ isAuthenticated,
8
+ getOrgId,
9
+ getFlowsDir,
10
+ loadLocalFlow,
11
+ saveLocalFlow,
12
+ listLocalFlows,
13
+ deleteLocalFlow,
14
+ } = require('../lib/config');
15
+ const {
16
+ error,
17
+ success,
18
+ info,
19
+ formatJson,
20
+ formatTable,
21
+ requireArgs,
22
+ readFile,
23
+ parseJson,
24
+ } = require('../lib/helpers');
25
+
26
+ // Webhook helper functions
27
+ const WEBHOOK_WORKER_URL = 'https://webhook-trigger-worker.jason-a5d.workers.dev';
28
+
29
+ /**
30
+ * Get webhook token for a workflow
31
+ */
32
+ async function getWebhookToken(workflowId) {
33
+ // Use container API URL for lux-api-2 routes
34
+ const containerUrl = process.env.CONTAINER_API_URL || 'http://localhost:8000';
35
+ const { data } = await axios.get(
36
+ `${containerUrl}/lux-api-2/workflows/${workflowId}/webhook-token`,
37
+ { headers: getAuthHeaders() }
38
+ );
39
+ return data;
40
+ }
41
+
42
+ /**
43
+ * Get captured webhook payload from worker
44
+ */
45
+ async function getCapturedPayload(token) {
46
+ const { data } = await axios.get(
47
+ `${WEBHOOK_WORKER_URL}/listening/poll/${token}`
48
+ );
49
+ return data;
50
+ }
51
+
52
+ /**
53
+ * Get workflow draft config
54
+ */
55
+ async function getWorkflowDraft(workflowId) {
56
+ const apiUrl = getApiUrl();
57
+ const { data } = await axios.get(
58
+ `${apiUrl}/api/workflows/${workflowId}/draft`,
59
+ { headers: getAuthHeaders() }
60
+ );
61
+ return data;
62
+ }
63
+
64
+ /**
65
+ * Save workflow draft config
66
+ */
67
+ async function saveWorkflowDraft(workflowId, config) {
68
+ const apiUrl = getApiUrl();
69
+ await axios.put(
70
+ `${apiUrl}/api/workflows/${workflowId}/draft`,
71
+ { config },
72
+ { headers: getAuthHeaders() }
73
+ );
74
+ }
75
+
76
+ async function handleWorkflows(args) {
77
+ // Check authentication
78
+ if (!isAuthenticated()) {
79
+ console.log(
80
+ chalk.red('āŒ Not authenticated. Run'),
81
+ chalk.white('lux login'),
82
+ chalk.red('first.')
83
+ );
84
+ process.exit(1);
85
+ }
86
+
87
+ const command = args[0];
88
+
89
+ if (!command) {
90
+ console.log(`
91
+ ${chalk.bold('Usage:')} lux workflows <command> [args]
92
+
93
+ ${chalk.bold('Commands:')}
94
+ list List all workflows (from local storage)
95
+ get <id> Get workflow details with config
96
+ status <id> Show sync status for a workflow
97
+ create <name> [desc] [--publish] Create workflow (use --publish to publish immediately)
98
+ init <name> [desc] [--publish] Initialize workflow (alias for create)
99
+ save <id> <config-file> Save draft config from file (local)
100
+ sync Sync published workflows from cloud
101
+ publish <id> Publish workflow to cloud
102
+ delete <id> Delete a local workflow
103
+ diff <id> Show local vs published differences
104
+
105
+ ${chalk.bold('Sync Status:')}
106
+ draft - Never published, local only
107
+ synced - Matches published version
108
+ dirty - Has local changes since publish
109
+ conflict - Local changes + cloud has newer version
110
+
111
+ ${chalk.bold('Webhook Commands:')}
112
+ webhook-url <id> Get webhook URL and status
113
+ webhook-listen <id> Start listening session for webhook capture
114
+ webhook-poll <id> Check for captured webhook payload
115
+ webhook-accept <id> Accept webhook format and save variables
116
+ webhook-decline <id> Decline webhook and reset session
117
+
118
+ ${chalk.bold('Examples:')}
119
+ lux workflows list
120
+ lux workflows get flow_123
121
+ lux workflows status flow_123
122
+ lux workflows create "My Workflow" "Description"
123
+ lux workflows create "My Workflow" --publish # Create and publish immediately
124
+ lux flow init "My Workflow" --publish
125
+ lux workflows save flow_123 ./config.json
126
+ lux workflows sync
127
+ lux workflows publish flow_123
128
+ lux workflows diff flow_123
129
+
130
+ ${chalk.bold('Webhook workflow:')}
131
+ lux flow webhook-url flow_123
132
+ lux flow webhook-listen flow_123
133
+ lux flow webhook-poll flow_123
134
+ lux flow webhook-accept flow_123
135
+ `);
136
+ process.exit(0);
137
+ }
138
+
139
+ // Use studio API (v2.uselux.ai) for workflow operations
140
+ const apiUrl = getStudioApiUrl();
141
+
142
+ try {
143
+ switch (command) {
144
+ case 'list': {
145
+ info('Loading workflows from local storage...');
146
+ const localFlows = listLocalFlows();
147
+
148
+ if (localFlows.length === 0) {
149
+ console.log('\n(No local workflows found)');
150
+ console.log(chalk.gray('Run "lux workflows sync" to sync from cloud, or "lux workflows create" to create one.\n'));
151
+ } else {
152
+ console.log(`\nFound ${localFlows.length} workflow(s):\n`);
153
+
154
+ // Color-code sync status
155
+ const statusColors = {
156
+ draft: chalk.gray,
157
+ synced: chalk.green,
158
+ dirty: chalk.yellow,
159
+ conflict: chalk.red,
160
+ };
161
+
162
+ const formatted = localFlows.map((w) => ({
163
+ id: w.id,
164
+ name: w.name,
165
+ sync: statusColors[w.syncStatus]?.(w.syncStatus) || w.syncStatus,
166
+ local_v: w.localVersion || 1,
167
+ published_v: w.publishedVersion || '-',
168
+ updated: w.updatedAt ? new Date(w.updatedAt).toLocaleDateString() : '-',
169
+ }));
170
+ formatTable(formatted);
171
+
172
+ // Show legend
173
+ console.log(chalk.gray('\nSync status: ') +
174
+ chalk.gray('draft') + ' | ' +
175
+ chalk.green('synced') + ' | ' +
176
+ chalk.yellow('dirty') + ' | ' +
177
+ chalk.red('conflict'));
178
+ }
179
+ break;
180
+ }
181
+
182
+ case 'status': {
183
+ requireArgs(args.slice(1), 1, 'lux workflows status <id>');
184
+ const workflowId = args[1];
185
+
186
+ const flow = loadLocalFlow(workflowId);
187
+ if (!flow) {
188
+ error(`Workflow not found locally: ${workflowId}`);
189
+ break;
190
+ }
191
+
192
+ // Determine sync status
193
+ let syncStatus = 'draft';
194
+ if (flow.publishedVersion) {
195
+ if (flow.cloudVersion && flow.cloudVersion > flow.publishedVersion) {
196
+ syncStatus = flow.localVersion > flow.publishedVersion ? 'conflict' : 'synced';
197
+ } else if (flow.localVersion > flow.publishedVersion) {
198
+ syncStatus = 'dirty';
199
+ } else {
200
+ syncStatus = 'synced';
201
+ }
202
+ }
203
+
204
+ const statusColors = {
205
+ draft: chalk.gray,
206
+ synced: chalk.green,
207
+ dirty: chalk.yellow,
208
+ conflict: chalk.red,
209
+ };
210
+
211
+ console.log(`\nšŸ“ Workflow: ${flow.name}`);
212
+ console.log(` ID: ${workflowId}`);
213
+ console.log(` Sync Status: ${statusColors[syncStatus](syncStatus)}`);
214
+ console.log(` Local Version: ${flow.localVersion || 1}`);
215
+ console.log(` Published Version: ${flow.publishedVersion || '(never published)'}`);
216
+ console.log(` Cloud Version: ${flow.cloudVersion || '(unknown)'}`);
217
+ console.log(` Last Updated: ${flow.updatedAt || '(unknown)'}`);
218
+ console.log(` Last Published: ${flow.lastPublishedAt || '(never)'}`);
219
+ console.log(` Nodes: ${flow.nodes?.length || 0}`);
220
+ console.log(` Edges: ${flow.edges?.length || 0}`);
221
+
222
+ if (syncStatus === 'conflict') {
223
+ console.log(chalk.red('\nāš ļø This workflow has conflicts with the cloud version.'));
224
+ console.log(chalk.gray(' Use the Lux Studio app to resolve conflicts.\n'));
225
+ } else if (syncStatus === 'dirty') {
226
+ console.log(chalk.yellow('\nšŸ“¤ This workflow has unpublished local changes.'));
227
+ console.log(chalk.gray(' Run "lux workflows publish ' + workflowId + '" to publish.\n'));
228
+ }
229
+ break;
230
+ }
231
+
232
+ case 'sync': {
233
+ info('Syncing workflows from cloud...');
234
+
235
+ const { data } = await axios.get(`${apiUrl}/api/workflows?include_config=true`, {
236
+ headers: getAuthHeaders(),
237
+ });
238
+
239
+ const cloudFlows = data.workflows || [];
240
+ let synced = 0;
241
+ let newFromCloud = 0;
242
+ let conflicts = 0;
243
+
244
+ for (const cloudFlow of cloudFlows) {
245
+ // Only sync published flows
246
+ if (cloudFlow.status !== 'published') continue;
247
+
248
+ const localFlow = loadLocalFlow(cloudFlow.id);
249
+ const now = new Date().toISOString();
250
+
251
+ if (!localFlow) {
252
+ // New from cloud
253
+ const newFlow = {
254
+ id: cloudFlow.id,
255
+ name: cloudFlow.name,
256
+ description: cloudFlow.description,
257
+ nodes: cloudFlow.config?.nodes || [],
258
+ edges: cloudFlow.config?.edges || [],
259
+ localVersion: cloudFlow.version || 1,
260
+ publishedVersion: cloudFlow.version || 1,
261
+ cloudVersion: cloudFlow.version || 1,
262
+ lastSyncedAt: now,
263
+ createdAt: cloudFlow.updated_at || cloudFlow.created_at,
264
+ updatedAt: cloudFlow.updated_at,
265
+ };
266
+ saveLocalFlow(cloudFlow.id, newFlow);
267
+ newFromCloud++;
268
+ } else {
269
+ const hasLocalChanges = localFlow.publishedVersion
270
+ ? localFlow.localVersion > localFlow.publishedVersion
271
+ : true;
272
+ const cloudVersion = cloudFlow.version || 1;
273
+ const cloudHasNewerVersion = cloudVersion > (localFlow.cloudVersion || 0);
274
+
275
+ if (hasLocalChanges && cloudHasNewerVersion) {
276
+ // Conflict
277
+ const updatedFlow = {
278
+ ...localFlow,
279
+ cloudVersion,
280
+ lastSyncedAt: now,
281
+ cloudData: {
282
+ nodes: cloudFlow.config?.nodes || [],
283
+ edges: cloudFlow.config?.edges || [],
284
+ version: cloudVersion,
285
+ updatedAt: cloudFlow.updated_at,
286
+ },
287
+ };
288
+ saveLocalFlow(cloudFlow.id, updatedFlow);
289
+ conflicts++;
290
+ } else if (cloudHasNewerVersion && !hasLocalChanges) {
291
+ // Update from cloud
292
+ const updatedFlow = {
293
+ ...localFlow,
294
+ name: cloudFlow.name,
295
+ description: cloudFlow.description,
296
+ nodes: cloudFlow.config?.nodes || [],
297
+ edges: cloudFlow.config?.edges || [],
298
+ localVersion: cloudVersion,
299
+ publishedVersion: cloudVersion,
300
+ cloudVersion,
301
+ lastSyncedAt: now,
302
+ updatedAt: cloudFlow.updated_at,
303
+ };
304
+ saveLocalFlow(cloudFlow.id, updatedFlow);
305
+ synced++;
306
+ } else {
307
+ // Up to date
308
+ const updatedFlow = {
309
+ ...localFlow,
310
+ cloudVersion,
311
+ lastSyncedAt: now,
312
+ };
313
+ saveLocalFlow(cloudFlow.id, updatedFlow);
314
+ }
315
+ }
316
+ }
317
+
318
+ success('Sync complete!');
319
+ console.log(` Synced: ${synced}`);
320
+ console.log(` New from cloud: ${newFromCloud}`);
321
+ if (conflicts > 0) {
322
+ console.log(chalk.red(` Conflicts: ${conflicts}`));
323
+ console.log(chalk.gray('\n Use "lux workflows list" to see workflows with conflicts.'));
324
+ }
325
+ break;
326
+ }
327
+
328
+ case 'delete': {
329
+ requireArgs(args.slice(1), 1, 'lux workflows delete <id>');
330
+ const workflowId = args[1];
331
+
332
+ const flow = loadLocalFlow(workflowId);
333
+ if (!flow) {
334
+ error(`Workflow not found locally: ${workflowId}`);
335
+ break;
336
+ }
337
+
338
+ if (flow.publishedVersion) {
339
+ console.log(chalk.yellow(`\nāš ļø Warning: "${flow.name}" has been published.`));
340
+ console.log(chalk.gray(' This only deletes the local copy. The published version remains in the cloud.\n'));
341
+ }
342
+
343
+ deleteLocalFlow(workflowId);
344
+ success(`Deleted local workflow: ${flow.name}`);
345
+ break;
346
+ }
347
+
348
+ case 'get': {
349
+ requireArgs(args.slice(1), 1, 'lux workflows get <id>');
350
+ const workflowId = args[1];
351
+
352
+ info(`Loading workflow: ${workflowId}`);
353
+ const { data } = await axios.get(
354
+ `${apiUrl}/api/workflows/${workflowId}`,
355
+ { headers: getAuthHeaders() }
356
+ );
357
+
358
+ if (!data.workflow) {
359
+ error(`Workflow not found: ${workflowId}`);
360
+ }
361
+
362
+ const workflow = data.workflow;
363
+
364
+ console.log(`\nšŸ“ Name: ${workflow.name}`);
365
+ console.log(`šŸ“Š Status: ${workflow.status}`);
366
+ console.log(`šŸ”¢ Version: ${workflow.version}`);
367
+ console.log(`šŸ“… Created: ${workflow.created_at}`);
368
+ console.log(`šŸ“… Updated: ${workflow.updated_at}`);
369
+
370
+ if (workflow.config) {
371
+ console.log(`\nšŸ“‹ Current Config:\n`);
372
+ console.log(formatJson(workflow.config));
373
+ }
374
+
375
+ break;
376
+ }
377
+
378
+ case 'create':
379
+ case 'init': {
380
+ // Check for --publish flag
381
+ const publishFlagIndex = args.indexOf('--publish');
382
+ const shouldPublish = publishFlagIndex !== -1;
383
+ if (shouldPublish) {
384
+ args.splice(publishFlagIndex, 1); // Remove flag from args
385
+ }
386
+
387
+ requireArgs(args.slice(1), 1, 'lux workflows create <name> [description] [--publish]');
388
+ const name = args[1];
389
+ const description = args[2] || '';
390
+
391
+ // Slugify the name for the flow ID
392
+ const slug = name
393
+ .toLowerCase()
394
+ .replace(/[^a-z0-9]+/g, '-')
395
+ .replace(/^-|-$/g, '');
396
+ const flowId = `${slug}-flow`;
397
+
398
+ // Check if flow already exists
399
+ const existingFlow = loadLocalFlow(flowId);
400
+ if (existingFlow) {
401
+ error(`Flow already exists: ${flowId}`);
402
+ console.log(chalk.gray(`Use a different name or delete the existing flow first.`));
403
+ break;
404
+ }
405
+
406
+ const now = new Date().toISOString();
407
+
408
+ // Create locally only - no API call
409
+ const newFlow = {
410
+ id: flowId,
411
+ name,
412
+ description,
413
+ nodes: [],
414
+ edges: [],
415
+ localVersion: 1,
416
+ createdAt: now,
417
+ updatedAt: now,
418
+ };
419
+
420
+ info(`Creating local workflow: ${name}`);
421
+
422
+ if (saveLocalFlow(flowId, newFlow)) {
423
+ success(`Workflow created locally!`);
424
+ console.log(` ID: ${flowId}`);
425
+
426
+ // If --publish flag was provided, publish immediately
427
+ if (shouldPublish) {
428
+ info(`Publishing workflow to cloud...`);
429
+
430
+ try {
431
+ const publishConfig = {
432
+ nodes: newFlow.nodes || [],
433
+ edges: newFlow.edges || [],
434
+ variables: newFlow.variables || {},
435
+ metadata: newFlow.metadata || {},
436
+ };
437
+
438
+ // Step 1: Create workflow in cloud database (this generates a cloud UUID)
439
+ const createResponse = await axios.post(
440
+ `${apiUrl}/api/workflows`,
441
+ {
442
+ name: newFlow.name,
443
+ description: newFlow.description,
444
+ config: publishConfig,
445
+ },
446
+ { headers: getAuthHeaders() }
447
+ );
448
+
449
+ const cloudId = createResponse.data.workflow?.id || createResponse.data.workflow?.workflow_id;
450
+ if (!cloudId) {
451
+ throw new Error('No workflow ID returned from cloud');
452
+ }
453
+
454
+ info(`Created in cloud with ID: ${cloudId}`);
455
+
456
+ // Step 2: Publish the workflow (copies draft config to published)
457
+ const publishResponse = await axios.post(
458
+ `${apiUrl}/api/workflows/${cloudId}/publish`,
459
+ {},
460
+ { headers: getAuthHeaders() }
461
+ );
462
+
463
+ const newVersion = publishResponse.data.version || 1;
464
+
465
+ // Update local storage with published version and cloud ID
466
+ const updatedFlow = {
467
+ ...newFlow,
468
+ cloudId: cloudId,
469
+ publishedVersion: newVersion,
470
+ cloudVersion: newVersion,
471
+ lastPublishedAt: new Date().toISOString(),
472
+ lastSyncedAt: new Date().toISOString(),
473
+ };
474
+ saveLocalFlow(flowId, updatedFlow);
475
+
476
+ success('Workflow published!');
477
+ console.log(` Cloud ID: ${cloudId}`);
478
+ console.log(` Status: ${chalk.green('published')}`);
479
+ console.log(` Version: ${newVersion}`);
480
+ } catch (publishError) {
481
+ error(`Failed to publish: ${publishError.response?.data?.error || publishError.message}`);
482
+ console.log(` Status: ${chalk.gray('draft')}`);
483
+ console.log(chalk.gray(`\nRun "lux workflows publish ${flowId}" to try again.`));
484
+ }
485
+ } else {
486
+ console.log(` Status: ${chalk.gray('draft')}`);
487
+ console.log(chalk.gray(`\n This workflow is local only. Run "lux workflows publish ${flowId}" to publish to cloud.`));
488
+ }
489
+ } else {
490
+ error('Failed to create workflow. Make sure you are authenticated.');
491
+ }
492
+ break;
493
+ }
494
+
495
+ case 'save': {
496
+ requireArgs(args.slice(1), 2, 'lux workflows save <id> <config-file>');
497
+ const workflowId = args[1];
498
+ const configFile = args[2];
499
+
500
+ // Load existing local flow
501
+ const existingFlow = loadLocalFlow(workflowId);
502
+ if (!existingFlow) {
503
+ error(`Workflow not found locally: ${workflowId}`);
504
+ console.log(chalk.gray('Run "lux workflows sync" to sync from cloud first.'));
505
+ break;
506
+ }
507
+
508
+ info(`Reading config from: ${configFile}`);
509
+ const configJson = readFile(configFile);
510
+ const config = parseJson(configJson, 'workflow config');
511
+
512
+ // Update local flow with new config
513
+ const updatedFlow = {
514
+ ...existingFlow,
515
+ nodes: config.nodes || existingFlow.nodes,
516
+ edges: config.edges || existingFlow.edges,
517
+ localVersion: (existingFlow.localVersion || 1) + 1,
518
+ updatedAt: new Date().toISOString(),
519
+ };
520
+
521
+ info(`Saving to local storage: ${workflowId}`);
522
+ if (saveLocalFlow(workflowId, updatedFlow)) {
523
+ success('Workflow saved locally!');
524
+ console.log(` Local Version: ${updatedFlow.localVersion}`);
525
+
526
+ // Show sync status
527
+ const hasPublished = existingFlow.publishedVersion;
528
+ if (!hasPublished) {
529
+ console.log(` Status: ${chalk.gray('draft')}`);
530
+ } else if (updatedFlow.localVersion > existingFlow.publishedVersion) {
531
+ console.log(` Status: ${chalk.yellow('dirty')} (has unpublished changes)`);
532
+ }
533
+
534
+ console.log(chalk.gray(`\nRun "lux workflows publish ${workflowId}" to publish to cloud.`));
535
+ } else {
536
+ error('Failed to save workflow locally.');
537
+ }
538
+ break;
539
+ }
540
+
541
+ case 'publish': {
542
+ requireArgs(args.slice(1), 1, 'lux workflows publish <id>');
543
+ const workflowId = args[1];
544
+
545
+ // Load local flow
546
+ const localFlow = loadLocalFlow(workflowId);
547
+ if (!localFlow) {
548
+ error(`Workflow not found locally: ${workflowId}`);
549
+ console.log(chalk.gray('Run "lux workflows sync" to sync from cloud first.'));
550
+ break;
551
+ }
552
+
553
+ // Check for conflicts
554
+ if (localFlow.cloudVersion && localFlow.publishedVersion &&
555
+ localFlow.cloudVersion > localFlow.publishedVersion &&
556
+ localFlow.localVersion > localFlow.publishedVersion) {
557
+ error('Cannot publish: workflow has conflicts with cloud version.');
558
+ console.log(chalk.gray('Resolve conflicts in Lux Studio first.'));
559
+ break;
560
+ }
561
+
562
+ info(`Publishing workflow: ${workflowId}`);
563
+
564
+ // Publish directly to cloud with config in request body
565
+ const publishConfig = {
566
+ nodes: localFlow.nodes || [],
567
+ edges: localFlow.edges || [],
568
+ variables: localFlow.variables || {},
569
+ metadata: localFlow.metadata || {},
570
+ };
571
+
572
+ const { data } = await axios.post(
573
+ `${apiUrl}/api/workflows/${workflowId}/publish`,
574
+ {
575
+ name: localFlow.name,
576
+ description: localFlow.description,
577
+ config: publishConfig,
578
+ },
579
+ { headers: getAuthHeaders() }
580
+ );
581
+
582
+ const newVersion = data.version || (localFlow.publishedVersion || 0) + 1;
583
+
584
+ // Update local storage with new published version
585
+ const updatedFlow = {
586
+ ...localFlow,
587
+ publishedVersion: newVersion,
588
+ cloudVersion: newVersion,
589
+ lastPublishedAt: new Date().toISOString(),
590
+ lastSyncedAt: new Date().toISOString(),
591
+ cloudData: undefined, // Clear any stored conflict data
592
+ };
593
+ saveLocalFlow(workflowId, updatedFlow);
594
+
595
+ success('Workflow published!');
596
+ console.log(` Name: ${localFlow.name}`);
597
+ console.log(` Version: ${newVersion}`);
598
+ console.log(` Status: ${chalk.green('synced')}`);
599
+ break;
600
+ }
601
+
602
+ case 'diff': {
603
+ requireArgs(args.slice(1), 1, 'lux workflows diff <id>');
604
+ const workflowId = args[1];
605
+
606
+ // Load local flow
607
+ const localFlow = loadLocalFlow(workflowId);
608
+ if (!localFlow) {
609
+ error(`Workflow not found locally: ${workflowId}`);
610
+ break;
611
+ }
612
+
613
+ info(`Comparing local vs published for: ${workflowId}`);
614
+
615
+ const localConfig = {
616
+ nodes: localFlow.nodes || [],
617
+ edges: localFlow.edges || [],
618
+ };
619
+
620
+ // Check if never published
621
+ if (!localFlow.publishedVersion) {
622
+ console.log('\nšŸ“ NEW WORKFLOW (not yet published)\n');
623
+ console.log(` Name: ${localFlow.name}`);
624
+ console.log(` Local Version: ${localFlow.localVersion || 1}`);
625
+ console.log(` Nodes: ${localConfig.nodes.length}`);
626
+ console.log(` Edges: ${localConfig.edges.length}`);
627
+ console.log(chalk.gray(`\nRun "lux workflows publish ${workflowId}" to publish.\n`));
628
+ break;
629
+ }
630
+
631
+ // Check for conflicts
632
+ if (localFlow.cloudData) {
633
+ console.log('\nāš ļø CONFLICT DETECTED\n');
634
+ console.log('Local version:');
635
+ console.log(` Version: ${localFlow.localVersion}`);
636
+ console.log(` Nodes: ${localConfig.nodes.length}`);
637
+ console.log(` Edges: ${localConfig.edges.length}`);
638
+ console.log('\nCloud version:');
639
+ console.log(` Version: ${localFlow.cloudData.version}`);
640
+ console.log(` Nodes: ${localFlow.cloudData.nodes?.length || 0}`);
641
+ console.log(` Edges: ${localFlow.cloudData.edges?.length || 0}`);
642
+ console.log(` Updated: ${localFlow.cloudData.updatedAt}`);
643
+ console.log(chalk.gray('\nResolve this conflict in Lux Studio.\n'));
644
+ break;
645
+ }
646
+
647
+ // Check if has local changes
648
+ const hasLocalChanges = localFlow.localVersion > localFlow.publishedVersion;
649
+
650
+ if (!hasLocalChanges) {
651
+ success('No changes - local matches published version');
652
+ console.log(` Published Version: ${localFlow.publishedVersion}`);
653
+ } else {
654
+ console.log('\nšŸ“Š LOCAL CHANGES (unpublished)\n');
655
+ console.log(` Local Version: ${localFlow.localVersion}`);
656
+ console.log(` Published Version: ${localFlow.publishedVersion}`);
657
+ console.log(` Nodes: ${localConfig.nodes.length}`);
658
+ console.log(` Edges: ${localConfig.edges.length}`);
659
+ console.log(chalk.gray(`\nRun "lux workflows publish ${workflowId}" to publish changes.\n`));
660
+ }
661
+ break;
662
+ }
663
+
664
+ case 'webhook-url': {
665
+ requireArgs(args.slice(1), 1, 'lux flow webhook-url <flow-id>');
666
+ const workflowId = args[1];
667
+
668
+ info(`Getting webhook URL for: ${workflowId}`);
669
+ const tokenData = await getWebhookToken(workflowId);
670
+
671
+ console.log(`\nšŸ“ Webhook URL:\n${tokenData.webhookUrl}\n`);
672
+ console.log(`šŸ“Š Status:`);
673
+ console.log(` Format Confirmed: ${tokenData.formatConfirmed ? 'āœ… Yes' : 'āŒ No'}`);
674
+ console.log(` Webhook Trigger Enabled: ${tokenData.webhookTrigger ? 'āœ… Yes' : 'āŒ No'}\n`);
675
+
676
+ if (!tokenData.formatConfirmed) {
677
+ console.log(chalk.yellow('ā„¹ļø Run "lux flow webhook-listen" to capture a sample webhook'));
678
+ }
679
+ break;
680
+ }
681
+
682
+ case 'webhook-listen': {
683
+ requireArgs(args.slice(1), 1, 'lux flow webhook-listen <flow-id>');
684
+ const workflowId = args[1];
685
+
686
+ info(`Starting webhook listening session for: ${workflowId}`);
687
+ const tokenData = await getWebhookToken(workflowId);
688
+
689
+ // Start listening session
690
+ await axios.post(`${WEBHOOK_WORKER_URL}/listening/start/${tokenData.token}`);
691
+
692
+ success('Listening session started!');
693
+ console.log(`\nšŸ“” Now listening for webhooks at:\n${tokenData.webhookUrl}\n`);
694
+ console.log('Session expires in 5 minutes.');
695
+ console.log(`\nSend a webhook request to the URL above, then run:`);
696
+ console.log(chalk.white(` lux flow webhook-poll ${workflowId}\n`));
697
+ break;
698
+ }
699
+
700
+ case 'webhook-poll': {
701
+ requireArgs(args.slice(1), 1, 'lux flow webhook-poll <flow-id>');
702
+ const workflowId = args[1];
703
+
704
+ const tokenData = await getWebhookToken(workflowId);
705
+ const pollData = await getCapturedPayload(tokenData.token);
706
+
707
+ // Return raw JSON response
708
+ console.log(formatJson(pollData));
709
+ break;
710
+ }
711
+
712
+ case 'webhook-accept': {
713
+ requireArgs(args.slice(1), 1, 'lux flow webhook-accept <flow-id>');
714
+ const workflowId = args[1];
715
+
716
+ info(`Accepting webhook format for: ${workflowId}`);
717
+
718
+ // Step 1: Get webhook token
719
+ const tokenData = await getWebhookToken(workflowId);
720
+
721
+ // Step 2: Poll for captured payload
722
+ const pollData = await getCapturedPayload(tokenData.token);
723
+
724
+ // Check if payload was captured
725
+ if (pollData.waiting || !pollData.payload) {
726
+ error('No webhook captured. Run "lux flow webhook-listen" first and send a test webhook.');
727
+ break;
728
+ }
729
+
730
+ const capturedPayload = pollData.payload;
731
+
732
+ // Step 3: Transform payload into schema
733
+ info('Creating webhook schema from captured payload...');
734
+ const schema = {};
735
+
736
+ Object.entries(capturedPayload).forEach(([key, value]) => {
737
+ const varType =
738
+ typeof value === 'number' ? 'number' :
739
+ typeof value === 'boolean' ? 'boolean' : 'string';
740
+
741
+ schema[key] = {
742
+ type: varType,
743
+ description: `Webhook parameter: ${key}`,
744
+ example: value,
745
+ };
746
+ });
747
+
748
+ // Step 4: Save schema to database (does NOT set format_confirmed yet)
749
+ info('Saving webhook schema to database...');
750
+ const containerUrl = process.env.CONTAINER_API_URL || 'http://localhost:8000';
751
+ await axios.put(`${containerUrl}/lux-api-2/webhooks/${tokenData.token}/schema`, {
752
+ schema,
753
+ }, {
754
+ headers: getAuthHeaders(),
755
+ });
756
+
757
+ // Step 5: Call worker accept endpoint (if not already confirmed)
758
+ info('Confirming webhook format with worker...');
759
+ let alreadyConfirmed = false;
760
+ try {
761
+ await axios.post(`${WEBHOOK_WORKER_URL}/listening/accept/${tokenData.token}`);
762
+ } catch (workerError) {
763
+ const errorMessage = workerError.response?.data?.error || '';
764
+ if (errorMessage.includes('already confirmed')) {
765
+ alreadyConfirmed = true;
766
+ info('Webhook already confirmed, updating schema only...');
767
+ } else {
768
+ throw workerError;
769
+ }
770
+ }
771
+
772
+ // Step 6: ONLY if worker accepted (first time), set format_confirmed to 1
773
+ if (!alreadyConfirmed) {
774
+ info('Setting format_confirmed flag...');
775
+ await axios.post(`${containerUrl}/lux-api-2/webhooks/${tokenData.token}/confirm`, {}, {
776
+ headers: getAuthHeaders(),
777
+ });
778
+ }
779
+
780
+ success('Webhook format updated!');
781
+ console.log(`\nāœ… Captured schema with ${Object.keys(schema).length} parameters`);
782
+ console.log(`āœ… Schema saved to database`);
783
+ if (!alreadyConfirmed) {
784
+ console.log(`āœ… Worker accepted format`);
785
+ console.log(`āœ… Format confirmed`);
786
+ } else {
787
+ console.log(`āœ… Schema updated (already confirmed)`);
788
+ }
789
+ console.log('');
790
+
791
+ // Show the schema
792
+ console.log('Captured schema:');
793
+ Object.entries(schema).forEach(([key, value]) => {
794
+ console.log(` ${key}: ${value.type}`);
795
+ });
796
+ console.log('');
797
+ break;
798
+ }
799
+
800
+ case 'webhook-decline': {
801
+ requireArgs(args.slice(1), 1, 'lux flow webhook-decline <flow-id>');
802
+ const workflowId = args[1];
803
+
804
+ info(`Declining webhook format for: ${workflowId}`);
805
+ const tokenData = await getWebhookToken(workflowId);
806
+
807
+ // Clear webhook schema from database
808
+ info('Clearing webhook schema...');
809
+ const containerUrl = process.env.CONTAINER_API_URL || 'http://localhost:8000';
810
+ await axios.delete(`${containerUrl}/lux-api-2/webhooks/${tokenData.token}/schema`, {
811
+ headers: getAuthHeaders(),
812
+ });
813
+
814
+ // Call worker decline endpoint (clears listening session)
815
+ await axios.delete(`${WEBHOOK_WORKER_URL}/listening/decline/${tokenData.token}`);
816
+
817
+ success('Webhook format declined!');
818
+ console.log('\nāœ… Schema cleared from database');
819
+ console.log('āœ… Listening session has been reset');
820
+ console.log(`\nRun "lux flow webhook-listen ${workflowId}" to try again.\n`);
821
+ break;
822
+ }
823
+
824
+ default:
825
+ error(
826
+ `Unknown command: ${command}\n\nRun 'lux workflows' to see available commands`
827
+ );
828
+ }
829
+ } catch (err) {
830
+ const errorMessage =
831
+ err.response?.data?.error || err.message || 'Unknown error';
832
+ error(`${errorMessage}`);
833
+ }
834
+ }
835
+
836
+ module.exports = { handleWorkflows };