luxlabs 1.0.15 → 1.0.17

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.
@@ -20,7 +20,7 @@ const chalk = require('chalk');
20
20
  const fs = require('fs');
21
21
  const path = require('path');
22
22
  const ora = require('ora');
23
- const { loadConfig, getProjectId, getStudioApiUrl } = require('../lib/config');
23
+ const { loadConfig, getProjectId, getStudioApiUrl, getInterfaceRepoDir, getInterfacesDir } = require('../lib/config');
24
24
 
25
25
  /**
26
26
  * Show help for ab-tests commands
@@ -52,65 +52,58 @@ function showHelp() {
52
52
 
53
53
  /**
54
54
  * Get the path to ab-tests.json for an interface
55
- * Supports both interface ID and interface name
55
+ * Uses ~/.lux-studio/{orgId}/projects/{projectId}/interfaces/{interfaceId}/repo/.lux/ab-tests.json
56
56
  */
57
57
  function getABTestsPath(interfaceIdentifier) {
58
- const interfacesDir = path.join(process.cwd(), 'interfaces');
59
-
60
- if (!fs.existsSync(interfacesDir)) {
61
- return null;
58
+ // First try the config-based path (preferred - used by Electron app)
59
+ if (interfaceIdentifier) {
60
+ const repoDir = getInterfaceRepoDir(interfaceIdentifier);
61
+ if (repoDir) {
62
+ const configPath = path.join(repoDir, '.lux', 'ab-tests.json');
63
+ if (fs.existsSync(configPath)) {
64
+ return configPath;
65
+ }
66
+ }
62
67
  }
63
68
 
64
- // If no identifier provided, try to find the only interface
65
- if (!interfaceIdentifier) {
69
+ // Try to find from interfaces dir if no identifier given
70
+ const interfacesDir = getInterfacesDir();
71
+ if (interfacesDir && fs.existsSync(interfacesDir)) {
66
72
  const entries = fs.readdirSync(interfacesDir, { withFileTypes: true });
67
73
  const dirs = entries.filter(e => e.isDirectory());
68
74
 
69
- if (dirs.length === 0) {
70
- return null;
75
+ if (!interfaceIdentifier && dirs.length === 1) {
76
+ // Auto-select the only interface
77
+ const repoDir = path.join(interfacesDir, dirs[0].name, 'repo');
78
+ const configPath = path.join(repoDir, '.lux', 'ab-tests.json');
79
+ if (fs.existsSync(configPath)) {
80
+ return configPath;
81
+ }
71
82
  }
72
83
 
73
- if (dirs.length === 1) {
74
- // Auto-select the only interface
75
- interfaceIdentifier = dirs[0].name;
76
- } else {
77
- // Multiple interfaces - need to specify
78
- console.log(chalk.yellow('Multiple interfaces found. Please specify which one:'));
79
- for (const dir of dirs) {
80
- const metaPath = path.join(interfacesDir, dir.name, 'metadata.json');
81
- let name = dir.name;
84
+ // Search by name if identifier doesn't match directly
85
+ if (interfaceIdentifier) {
86
+ for (const entry of entries) {
87
+ if (!entry.isDirectory()) continue;
88
+
89
+ const metaPath = path.join(interfacesDir, entry.name, 'metadata.json');
82
90
  if (fs.existsSync(metaPath)) {
83
91
  try {
84
92
  const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
85
- name = meta.name || dir.name;
93
+ if (meta.name && meta.name.toLowerCase() === interfaceIdentifier.toLowerCase()) {
94
+ const repoDir = path.join(interfacesDir, entry.name, 'repo');
95
+ return path.join(repoDir, '.lux', 'ab-tests.json');
96
+ }
86
97
  } catch (e) { /* ignore */ }
87
98
  }
88
- console.log(chalk.dim(` - ${name} (${dir.name})`));
89
99
  }
90
- return null;
91
100
  }
92
101
  }
93
102
 
94
- // Check if it's a direct interface ID (directory exists)
95
- let interfaceDir = path.join(interfacesDir, interfaceIdentifier, 'repo');
96
- if (fs.existsSync(interfaceDir)) {
97
- return path.join(interfaceDir, '.lux', 'ab-tests.json');
98
- }
99
-
100
- // Try to find by name
101
- const entries = fs.readdirSync(interfacesDir, { withFileTypes: true });
102
- for (const entry of entries) {
103
- if (!entry.isDirectory()) continue;
104
-
105
- const metaPath = path.join(interfacesDir, entry.name, 'metadata.json');
106
- if (fs.existsSync(metaPath)) {
107
- try {
108
- const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
109
- if (meta.name && meta.name.toLowerCase() === interfaceIdentifier.toLowerCase()) {
110
- return path.join(interfacesDir, entry.name, 'repo', '.lux', 'ab-tests.json');
111
- }
112
- } catch (e) { /* ignore */ }
113
- }
103
+ // Fallback: try cwd-based path (for running from interface repo directory)
104
+ const cwdPath = path.join(process.cwd(), '.lux', 'ab-tests.json');
105
+ if (fs.existsSync(cwdPath)) {
106
+ return cwdPath;
114
107
  }
115
108
 
116
109
  return null;
package/commands/data.js CHANGED
@@ -182,6 +182,12 @@ ${chalk.bold('Table Commands:')}
182
182
  tables export <table-name> [file] Export table to CSV
183
183
  tables import <table-name> <csv-file> Import CSV to table
184
184
 
185
+ ${chalk.bold('Column Commands:')}
186
+ tables add-column <table> <name> <type> [--not-null] [--default <val>] Add column
187
+ tables drop-column <table> <column> Delete column
188
+ tables rename-column <table> <old> <new> Rename column
189
+ tables change-type <table> <column> <type> Change column type (if empty)
190
+
185
191
  ${chalk.bold('KV Commands:')}
186
192
  kv list List all KV namespaces
187
193
  kv init <name> [description] Create new KV namespace
@@ -585,6 +591,121 @@ ${chalk.bold('Examples:')}
585
591
  break;
586
592
  }
587
593
 
594
+ // ============ COLUMN OPERATIONS ============
595
+
596
+ case 'add-column': {
597
+ requireArgs(args.slice(2), 3, 'lux data tables add-column <table-name> <column-name> <type> [--not-null] [--default <value>]');
598
+ const tableName = args[2];
599
+ const columnName = args[3];
600
+ const columnType = args[4].toUpperCase();
601
+
602
+ // Parse flags
603
+ const notNull = args.includes('--not-null');
604
+ const defaultIndex = args.indexOf('--default');
605
+ const defaultValue = defaultIndex !== -1 ? args[defaultIndex + 1] : undefined;
606
+
607
+ info(`Adding column '${columnName}' to table '${tableName}'...`);
608
+ const tablesApiUrl = getTablesApiUrl();
609
+
610
+ const columnDef = {
611
+ name: columnName,
612
+ type: columnType,
613
+ notNull,
614
+ };
615
+ if (defaultValue !== undefined) {
616
+ // Try to parse as number or keep as string
617
+ const parsed = Number(defaultValue);
618
+ columnDef.defaultValue = isNaN(parsed) ? defaultValue : parsed;
619
+ }
620
+
621
+ const { data } = await axios.post(
622
+ `${tablesApiUrl}/${encodeURIComponent(tableName)}/columns`,
623
+ { column: columnDef },
624
+ { headers: getStudioAuthHeaders() }
625
+ );
626
+
627
+ success(`Column '${columnName}' added!`);
628
+ console.log(` Type: ${data.column.type}`);
629
+ if (data.column.notNull) console.log(` NOT NULL: Yes`);
630
+ break;
631
+ }
632
+
633
+ case 'drop-column': {
634
+ requireArgs(args.slice(2), 2, 'lux data tables drop-column <table-name> <column-name>');
635
+ const tableName = args[2];
636
+ const columnName = args[3];
637
+
638
+ info(`Dropping column '${columnName}' from table '${tableName}'...`);
639
+ const tablesApiUrl = getTablesApiUrl();
640
+
641
+ await axios.delete(
642
+ `${tablesApiUrl}/${encodeURIComponent(tableName)}/columns/${encodeURIComponent(columnName)}`,
643
+ { headers: getStudioAuthHeaders() }
644
+ );
645
+
646
+ success(`Column '${columnName}' deleted!`);
647
+ break;
648
+ }
649
+
650
+ case 'rename-column': {
651
+ requireArgs(args.slice(2), 3, 'lux data tables rename-column <table-name> <old-name> <new-name>');
652
+ const tableName = args[2];
653
+ const oldName = args[3];
654
+ const newName = args[4];
655
+
656
+ info(`Renaming column '${oldName}' to '${newName}'...`);
657
+ const tablesApiUrl = getTablesApiUrl();
658
+
659
+ await axios.patch(
660
+ `${tablesApiUrl}/${encodeURIComponent(tableName)}/columns/${encodeURIComponent(oldName)}`,
661
+ { newName },
662
+ { headers: getStudioAuthHeaders() }
663
+ );
664
+
665
+ success(`Column renamed from '${oldName}' to '${newName}'!`);
666
+ break;
667
+ }
668
+
669
+ case 'change-type': {
670
+ requireArgs(args.slice(2), 3, 'lux data tables change-type <table-name> <column-name> <new-type>');
671
+ const tableName = args[2];
672
+ const columnName = args[3];
673
+ const newType = args[4].toUpperCase();
674
+
675
+ // Validate type
676
+ const validTypes = ['TEXT', 'INTEGER', 'REAL'];
677
+ if (!validTypes.includes(newType)) {
678
+ error(`Invalid type '${newType}'. Must be one of: ${validTypes.join(', ')}`);
679
+ process.exit(1);
680
+ }
681
+
682
+ // First check if column has data
683
+ info(`Checking if column '${columnName}' has data...`);
684
+ const tablesApiUrl = getTablesApiUrl();
685
+
686
+ const { data: countData } = await axios.get(
687
+ `${tablesApiUrl}/${encodeURIComponent(tableName)}/columns/${encodeURIComponent(columnName)}/count`,
688
+ { headers: getStudioAuthHeaders() }
689
+ );
690
+
691
+ if (countData.hasData) {
692
+ error(`Cannot change type: column '${columnName}' has ${countData.recordCount} non-null values.`);
693
+ console.log(chalk.yellow(' Clear the column data first, or drop and recreate the column.'));
694
+ process.exit(1);
695
+ }
696
+
697
+ info(`Changing column type to ${newType}...`);
698
+
699
+ await axios.put(
700
+ `${tablesApiUrl}/${encodeURIComponent(tableName)}/columns/${encodeURIComponent(columnName)}/type`,
701
+ { newType },
702
+ { headers: getStudioAuthHeaders() }
703
+ );
704
+
705
+ success(`Column '${columnName}' type changed to ${newType}!`);
706
+ break;
707
+ }
708
+
588
709
  default:
589
710
  error(`Unknown table subcommand: ${subCommand}`);
590
711
  }
@@ -233,6 +233,27 @@ async function wait(interfaceId, ms) {
233
233
  return result;
234
234
  }
235
235
 
236
+ /**
237
+ * Refresh the webview (soft or hard refresh)
238
+ */
239
+ async function refresh(interfaceId, hard = false) {
240
+ const id = generateId();
241
+ const result = await sendCommand({
242
+ id,
243
+ type: 'refresh',
244
+ appId: interfaceId,
245
+ payload: { hard },
246
+ });
247
+
248
+ if (result.success) {
249
+ console.log(chalk.green(hard ? 'Hard refresh completed!' : 'Refresh completed!'));
250
+ } else {
251
+ console.log(chalk.red('Refresh failed:'), result.error);
252
+ }
253
+
254
+ return result;
255
+ }
256
+
236
257
  /**
237
258
  * Start the interface preview in Lux Studio
238
259
  */
@@ -265,6 +286,7 @@ function showHelp() {
265
286
  console.log(' lux test url <interface-id> Get current URL');
266
287
  console.log(' lux test navigate <interface-id> <url> Navigate to URL');
267
288
  console.log(' lux test wait <interface-id> <ms> Wait for duration');
289
+ console.log(' lux test refresh <interface-id> [--hard] Refresh the page');
268
290
  console.log('');
269
291
  console.log(chalk.cyan('Examples:'));
270
292
  console.log(' lux test screenshot my-interface ./screenshot.png');
@@ -272,6 +294,7 @@ function showHelp() {
272
294
  console.log(' lux test type my-interface "#email" "user@example.com"');
273
295
  console.log(' lux test eval my-interface "document.title"');
274
296
  console.log(' lux test navigate my-interface "http://localhost:3000/login"');
297
+ console.log(' lux test refresh my-interface --hard');
275
298
  console.log('');
276
299
  console.log(chalk.dim('Note: <interface-id> is the interface ID or name from "lux servers"'));
277
300
  console.log('');
@@ -370,6 +393,18 @@ async function handleTest(args = []) {
370
393
  break;
371
394
  }
372
395
 
396
+ case 'refresh': {
397
+ const [interfaceId, ...flags] = rest;
398
+ if (!interfaceId) {
399
+ console.log(chalk.red('Error: interface-id is required'));
400
+ showHelp();
401
+ return;
402
+ }
403
+ const hard = flags.includes('--hard') || flags.includes('-h');
404
+ await refresh(interfaceId, hard);
405
+ break;
406
+ }
407
+
373
408
  default:
374
409
  console.log(chalk.red(`Unknown test command: ${subcommand}`));
375
410
  showHelp();
@@ -388,6 +423,7 @@ module.exports = {
388
423
  getUrl,
389
424
  navigate,
390
425
  wait,
426
+ refresh,
391
427
  startPreview,
392
428
  sendCommand,
393
429
  };
@@ -102,6 +102,11 @@ ${chalk.bold('Commands:')}
102
102
  delete <id> Delete a local workflow
103
103
  diff <id> Show local vs published differences
104
104
 
105
+ ${chalk.bold('Execution History:')}
106
+ executions <flow-id> [--limit N] List execution history for a flow
107
+ execution <flow-id> <exec-id> Get full execution details
108
+ node <flow-id> <exec-id> <node-id> Get specific node execution details
109
+
105
110
  ${chalk.bold('Sync Status:')}
106
111
  draft - Never published, local only
107
112
  synced - Matches published version
@@ -127,6 +132,12 @@ ${chalk.bold('Examples:')}
127
132
  lux workflows publish flow_123
128
133
  lux workflows diff flow_123
129
134
 
135
+ ${chalk.bold('Execution history:')}
136
+ lux flow executions my-flow-id
137
+ lux flow executions my-flow-id --limit 50
138
+ lux flow execution my-flow-id exec_abc123
139
+ lux flow node my-flow-id exec_abc123 node-1
140
+
130
141
  ${chalk.bold('Webhook workflow:')}
131
142
  lux flow webhook-url flow_123
132
143
  lux flow webhook-listen flow_123
@@ -851,6 +862,229 @@ ${chalk.bold('Examples:')}
851
862
  break;
852
863
  }
853
864
 
865
+ // ============ EXECUTION HISTORY COMMANDS ============
866
+
867
+ case 'executions': {
868
+ requireArgs(args.slice(1), 1, 'lux flow executions <flow-id> [--limit N]');
869
+ const flowId = args[1];
870
+
871
+ // Parse --limit flag
872
+ const limitIndex = args.indexOf('--limit');
873
+ const limit = limitIndex !== -1 && args[limitIndex + 1] ? parseInt(args[limitIndex + 1], 10) : 20;
874
+
875
+ info(`Fetching execution history for: ${flowId}`);
876
+
877
+ const { data } = await axios.get(
878
+ `${apiUrl}/api/flows/${flowId}/executions?limit=${limit}`,
879
+ { headers: getAuthHeaders() }
880
+ );
881
+
882
+ if (!data.executions || data.executions.length === 0) {
883
+ console.log(chalk.dim('\nNo executions found for this flow.\n'));
884
+ break;
885
+ }
886
+
887
+ console.log(`\n📊 Execution History for: ${flowId}\n`);
888
+
889
+ // Status colors
890
+ const statusColors = {
891
+ completed: chalk.green,
892
+ running: chalk.cyan,
893
+ failed: chalk.red,
894
+ cancelled: chalk.yellow,
895
+ };
896
+
897
+ const formatted = data.executions.map((exec) => {
898
+ const statusColor = statusColors[exec.status] || chalk.white;
899
+ const duration = exec.durationMs ? `${exec.durationMs}ms` : '-';
900
+ const startedAt = exec.startedAt ? new Date(exec.startedAt).toLocaleString() : '-';
901
+ const nodeCount = exec.nodeExecutions?.length || 0;
902
+
903
+ return {
904
+ id: exec.id.substring(0, 16) + '...',
905
+ status: statusColor(exec.status),
906
+ trigger: exec.triggerType || '-',
907
+ duration: duration,
908
+ nodes: nodeCount,
909
+ started: startedAt,
910
+ test: exec.isTest ? chalk.yellow('test') : '',
911
+ };
912
+ });
913
+
914
+ formatTable(formatted);
915
+
916
+ if (data.pagination?.hasMore) {
917
+ console.log(chalk.dim(`\nShowing ${data.executions.length} of ${data.pagination.total} executions.`));
918
+ console.log(chalk.dim(`Use --limit N to see more.\n`));
919
+ }
920
+
921
+ console.log(chalk.gray(`\nTo see full details: lux flow execution ${flowId} <execution-id>\n`));
922
+ break;
923
+ }
924
+
925
+ case 'execution': {
926
+ requireArgs(args.slice(1), 2, 'lux flow execution <flow-id> <execution-id>');
927
+ const flowId = args[1];
928
+ const executionId = args[2];
929
+
930
+ info(`Fetching execution: ${executionId}`);
931
+
932
+ const { data } = await axios.get(
933
+ `${apiUrl}/api/flows/${flowId}/executions/${executionId}`,
934
+ { headers: getAuthHeaders() }
935
+ );
936
+
937
+ if (!data.execution) {
938
+ error(`Execution not found: ${executionId}`);
939
+ break;
940
+ }
941
+
942
+ const exec = data.execution;
943
+ const statusColors = {
944
+ completed: chalk.green,
945
+ running: chalk.cyan,
946
+ failed: chalk.red,
947
+ cancelled: chalk.yellow,
948
+ };
949
+ const statusColor = statusColors[exec.status] || chalk.white;
950
+
951
+ console.log(`\n📋 Execution Details\n`);
952
+ console.log(` ID: ${exec.id}`);
953
+ console.log(` Flow: ${exec.flowId}`);
954
+ console.log(` Status: ${statusColor(exec.status)}`);
955
+ console.log(` Trigger: ${exec.triggerType || '-'}`);
956
+ console.log(` Duration: ${exec.durationMs ? exec.durationMs + 'ms' : '-'}`);
957
+ console.log(` Started: ${exec.startedAt ? new Date(exec.startedAt).toLocaleString() : '-'}`);
958
+ console.log(` Completed: ${exec.completedAt ? new Date(exec.completedAt).toLocaleString() : '-'}`);
959
+ console.log(` Version: ${exec.flowVersion || '-'}`);
960
+ console.log(` Test Run: ${exec.isTest ? chalk.yellow('Yes') : 'No'}`);
961
+
962
+ if (exec.error) {
963
+ console.log(`\n${chalk.red('❌ Error:')}`);
964
+ console.log(` ${exec.error}`);
965
+ }
966
+
967
+ // Show input data
968
+ if (exec.inputData && Object.keys(exec.inputData).length > 0) {
969
+ console.log(`\n${chalk.cyan('📥 Input Data:')}`);
970
+ console.log(formatJson(exec.inputData));
971
+ }
972
+
973
+ // Show output data
974
+ if (exec.outputData && Object.keys(exec.outputData).length > 0) {
975
+ console.log(`\n${chalk.cyan('📤 Output Data:')}`);
976
+ console.log(formatJson(exec.outputData));
977
+ }
978
+
979
+ // Show node executions summary
980
+ if (exec.nodeExecutions && exec.nodeExecutions.length > 0) {
981
+ console.log(`\n${chalk.cyan('🔗 Node Executions:')} (${exec.nodeExecutions.length} nodes)\n`);
982
+
983
+ const nodeFormatted = exec.nodeExecutions.map((node) => {
984
+ const nodeStatus = node.status || 'unknown';
985
+ const nodeStatusColor = statusColors[nodeStatus] || chalk.white;
986
+ const nodeDuration = node.durationMs ? `${node.durationMs}ms` : '-';
987
+
988
+ return {
989
+ node_id: node.nodeId || node.id,
990
+ type: node.nodeType || '-',
991
+ status: nodeStatusColor(nodeStatus),
992
+ duration: nodeDuration,
993
+ error: node.error ? chalk.red('⚠') : '',
994
+ };
995
+ });
996
+
997
+ formatTable(nodeFormatted);
998
+
999
+ console.log(chalk.gray(`\nTo see node details: lux flow node ${flowId} ${executionId} <node-id>\n`));
1000
+ }
1001
+
1002
+ break;
1003
+ }
1004
+
1005
+ case 'node': {
1006
+ requireArgs(args.slice(1), 3, 'lux flow node <flow-id> <execution-id> <node-id>');
1007
+ const flowId = args[1];
1008
+ const executionId = args[2];
1009
+ const nodeId = args[3];
1010
+
1011
+ info(`Fetching node execution: ${nodeId}`);
1012
+
1013
+ const { data } = await axios.get(
1014
+ `${apiUrl}/api/flows/${flowId}/executions/${executionId}`,
1015
+ { headers: getAuthHeaders() }
1016
+ );
1017
+
1018
+ if (!data.execution) {
1019
+ error(`Execution not found: ${executionId}`);
1020
+ break;
1021
+ }
1022
+
1023
+ const exec = data.execution;
1024
+ const nodeExec = exec.nodeExecutions?.find(
1025
+ (n) => n.nodeId === nodeId || n.id === nodeId
1026
+ );
1027
+
1028
+ if (!nodeExec) {
1029
+ error(`Node not found in execution: ${nodeId}`);
1030
+ console.log(chalk.dim(`\nAvailable nodes:`));
1031
+ exec.nodeExecutions?.forEach((n) => {
1032
+ console.log(chalk.dim(` - ${n.nodeId || n.id} (${n.nodeType || 'unknown'})`));
1033
+ });
1034
+ break;
1035
+ }
1036
+
1037
+ const statusColors = {
1038
+ completed: chalk.green,
1039
+ running: chalk.cyan,
1040
+ failed: chalk.red,
1041
+ cancelled: chalk.yellow,
1042
+ skipped: chalk.gray,
1043
+ };
1044
+ const statusColor = statusColors[nodeExec.status] || chalk.white;
1045
+
1046
+ console.log(`\n🔗 Node Execution Details\n`);
1047
+ console.log(` Node ID: ${nodeExec.nodeId || nodeExec.id}`);
1048
+ console.log(` Type: ${nodeExec.nodeType || '-'}`);
1049
+ console.log(` Label: ${nodeExec.label || '-'}`);
1050
+ console.log(` Status: ${statusColor(nodeExec.status || 'unknown')}`);
1051
+ console.log(` Duration: ${nodeExec.durationMs ? nodeExec.durationMs + 'ms' : '-'}`);
1052
+ console.log(` Started: ${nodeExec.startedAt ? new Date(nodeExec.startedAt).toLocaleString() : '-'}`);
1053
+ console.log(` Completed: ${nodeExec.completedAt ? new Date(nodeExec.completedAt).toLocaleString() : '-'}`);
1054
+
1055
+ if (nodeExec.error) {
1056
+ console.log(`\n${chalk.red('❌ Error:')}`);
1057
+ console.log(` ${nodeExec.error}`);
1058
+ }
1059
+
1060
+ // Show node config
1061
+ if (nodeExec.config && Object.keys(nodeExec.config).length > 0) {
1062
+ console.log(`\n${chalk.cyan('⚙️ Node Config:')}`);
1063
+ console.log(formatJson(nodeExec.config));
1064
+ }
1065
+
1066
+ // Show input
1067
+ if (nodeExec.input !== undefined) {
1068
+ console.log(`\n${chalk.cyan('📥 Input:')}`);
1069
+ console.log(formatJson(nodeExec.input));
1070
+ }
1071
+
1072
+ // Show output
1073
+ if (nodeExec.output !== undefined) {
1074
+ console.log(`\n${chalk.cyan('📤 Output:')}`);
1075
+ console.log(formatJson(nodeExec.output));
1076
+ }
1077
+
1078
+ // Show raw data if nothing else
1079
+ if (!nodeExec.input && !nodeExec.output && !nodeExec.config) {
1080
+ console.log(`\n${chalk.cyan('📋 Raw Node Data:')}`);
1081
+ console.log(formatJson(nodeExec));
1082
+ }
1083
+
1084
+ console.log('');
1085
+ break;
1086
+ }
1087
+
854
1088
  default:
855
1089
  error(
856
1090
  `Unknown command: ${command}\n\nRun 'lux workflows' to see available commands`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "luxlabs",
3
- "version": "1.0.15",
3
+ "version": "1.0.17",
4
4
  "description": "CLI tool for Lux - Upload and deploy interfaces from your terminal",
5
5
  "author": "Jason Henkel <jason@uselux.ai>",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -11,9 +11,22 @@ const LUX_INTERFACE_ID = process.env.NEXT_PUBLIC_LUX_INTERFACE_ID
11
11
  const LUX_ORG_ID = process.env.NEXT_PUBLIC_LUX_ORG_ID
12
12
 
13
13
  /**
14
- * Parse Lux Studio flag overrides from URL query params.
14
+ * LUX STUDIO A/B TEST PREVIEW MODE
15
+ *
15
16
  * When previewing A/B test variants in Lux Studio, the preview URL includes
16
17
  * ?__lux_flag_overrides={"flag-key":"variant-key"} to force specific variants.
18
+ *
19
+ * If no real PostHog API key is configured (NEXT_PUBLIC_POSTHOG_KEY), we use a
20
+ * fake/dummy key ('phc_lux_preview_mode') to initialize PostHog just enough for
21
+ * the feature flag override system to work. This allows A/B test previews to
22
+ * function during local development without a real PostHog account.
23
+ *
24
+ * The dummy key disables all analytics (autocapture, session recording, etc.)
25
+ * so no data is sent to PostHog - it's purely for local flag overrides.
26
+ */
27
+
28
+ /**
29
+ * Parse Lux Studio flag overrides from URL query params.
17
30
  */
18
31
  function getLuxFlagOverrides(): Record<string, string> | undefined {
19
32
  if (typeof window === 'undefined') return undefined
@@ -32,24 +45,37 @@ function getLuxFlagOverrides(): Record<string, string> | undefined {
32
45
  return undefined
33
46
  }
34
47
 
48
+ /** Check if we're in Lux Studio preview mode (have flag overrides in URL) */
49
+ function isLuxPreviewMode(): boolean {
50
+ return getLuxFlagOverrides() !== undefined
51
+ }
52
+
35
53
  export function PostHogProvider({ children }: { children: React.ReactNode }) {
36
54
  const initialized = useRef(false)
37
55
  const searchParams = useSearchParams()
38
56
 
39
- // Initialize PostHog once
57
+ // Initialize PostHog - use a dummy key for preview mode if no real key
40
58
  useEffect(() => {
41
- if (!POSTHOG_KEY || initialized.current) {
42
- if (!POSTHOG_KEY) {
43
- console.log('[PostHog] No API key configured, skipping initialization')
44
- }
59
+ if (initialized.current) return
60
+
61
+ const hasOverrides = isLuxPreviewMode()
62
+ // Use real key if available, otherwise use dummy key for preview mode only
63
+ // The dummy key 'phc_lux_preview_mode' is NOT a real PostHog key - it just
64
+ // allows the PostHog SDK to initialize so flag overrides work locally
65
+ const keyToUse = POSTHOG_KEY || (hasOverrides ? 'phc_lux_preview_mode' : null)
66
+
67
+ if (!keyToUse) {
68
+ console.log('[PostHog] No API key configured and not in preview mode, skipping initialization')
45
69
  return
46
70
  }
47
71
 
48
- posthog.init(POSTHOG_KEY, {
72
+ posthog.init(keyToUse, {
49
73
  api_host: POSTHOG_HOST,
50
74
  persistence: 'localStorage',
51
75
  capture_pageview: false, // We capture manually for better control
52
- capture_pageleave: true,
76
+ capture_pageleave: !hasOverrides, // Don't capture in preview mode without real key
77
+ autocapture: POSTHOG_KEY ? true : false, // Disable autocapture without real key
78
+ disable_session_recording: !POSTHOG_KEY, // Disable recording without real key
53
79
  loaded: (ph) => {
54
80
  // Register lux properties with all events for filtering
55
81
  const props: Record<string, string> = {}
@@ -65,25 +91,32 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) {
65
91
  }, [])
66
92
 
67
93
  // Apply Lux Studio flag overrides whenever URL changes
68
- // This uses posthog.featureFlags.override() which works even after init
94
+ // Uses posthog.featureFlags.overrideFeatureFlags() - the correct API
69
95
  useEffect(() => {
70
- if (!POSTHOG_KEY) return
96
+ // Only skip if PostHog not initialized at all
97
+ if (!initialized.current && !POSTHOG_KEY && !isLuxPreviewMode()) return
71
98
 
72
99
  const flagOverrides = getLuxFlagOverrides()
73
- if (flagOverrides) {
74
- // Override feature flags for Lux Studio preview
75
- posthog.featureFlags.override(flagOverrides)
100
+
101
+ if (flagOverrides && Object.keys(flagOverrides).length > 0) {
102
+ // IMPORTANT: overrideFeatureFlags expects { flags: { [key]: value } }
103
+ posthog.featureFlags.overrideFeatureFlags({
104
+ flags: flagOverrides,
105
+ })
76
106
  console.log('[PostHog] Applied Lux Studio flag overrides:', flagOverrides)
77
107
 
78
108
  // Force reload to make React hooks aware of the change
79
109
  posthog.reloadFeatureFlags()
80
- } else {
110
+ } else if (initialized.current) {
81
111
  // Clear any previous overrides when not in preview mode
82
- posthog.featureFlags.override(false)
112
+ posthog.featureFlags.overrideFeatureFlags({ flags: {} })
83
113
  }
84
114
  }, [searchParams])
85
115
 
86
- if (!POSTHOG_KEY) {
116
+ // Always use PHProvider if we're initialized (either with real key or preview mode)
117
+ const shouldUsePHProvider = POSTHOG_KEY || isLuxPreviewMode()
118
+
119
+ if (!shouldUsePHProvider) {
87
120
  return <>{children}</>
88
121
  }
89
122
 
@@ -0,0 +1,404 @@
1
+ /**
2
+ * Lux Knowledge Upload Library
3
+ *
4
+ * UI-agnostic utilities for uploading files to your project's knowledge bucket.
5
+ * Uses presigned URLs for efficient large file support without base64 encoding.
6
+ *
7
+ * @example
8
+ * // Basic upload
9
+ * import { uploadToKnowledge } from '@/lib/knowledge';
10
+ *
11
+ * const result = await uploadToKnowledge(file);
12
+ * console.log('Uploaded to:', result.path);
13
+ *
14
+ * @example
15
+ * // Upload to specific folder
16
+ * const result = await uploadToKnowledge(file, {
17
+ * folder: 'documents/invoices',
18
+ * });
19
+ *
20
+ * @example
21
+ * // Upload with landing zone (files go to 'uploads/' first)
22
+ * const knowledge = createKnowledgeUploader({
23
+ * landingZone: 'uploads',
24
+ * });
25
+ * const result = await knowledge.upload(file);
26
+ *
27
+ * @example
28
+ * // Upload with progress tracking
29
+ * const result = await uploadToKnowledge(file, {
30
+ * onProgress: (percent) => setProgress(percent),
31
+ * });
32
+ */
33
+
34
+ // ============================================
35
+ // Types
36
+ // ============================================
37
+
38
+ export interface UploadOptions {
39
+ /** Subfolder path within knowledge (e.g., 'documents/invoices') */
40
+ folder?: string;
41
+ /** Custom filename (defaults to original file name) */
42
+ filename?: string;
43
+ /** Override content type (defaults to file.type or 'application/octet-stream') */
44
+ contentType?: string;
45
+ /** Progress callback (0-100) */
46
+ onProgress?: (percent: number) => void;
47
+ /** Device ID for sync tracking */
48
+ deviceId?: string;
49
+ }
50
+
51
+ export interface UploadResult {
52
+ /** Whether the upload succeeded */
53
+ success: boolean;
54
+ /** Full path within knowledge bucket */
55
+ path: string;
56
+ /** File size in bytes */
57
+ size: number;
58
+ /** Any error message if failed */
59
+ error?: string;
60
+ }
61
+
62
+ export interface KnowledgeConfig {
63
+ /** API base URL (defaults to env or window config) */
64
+ apiUrl?: string;
65
+ /** API key for authentication */
66
+ apiKey?: string;
67
+ /** Project ID */
68
+ projectId?: string;
69
+ /** Default folder for all uploads */
70
+ landingZone?: string;
71
+ /** Device ID for sync tracking */
72
+ deviceId?: string;
73
+ }
74
+
75
+ // ============================================
76
+ // Configuration
77
+ // ============================================
78
+
79
+ let globalConfig: KnowledgeConfig = {};
80
+
81
+ /**
82
+ * Configure the knowledge uploader globally
83
+ */
84
+ export function configureKnowledge(config: KnowledgeConfig): void {
85
+ globalConfig = { ...globalConfig, ...config };
86
+ }
87
+
88
+ /**
89
+ * Get API URL from config, window, or environment
90
+ */
91
+ function getApiUrl(): string {
92
+ if (globalConfig.apiUrl) return globalConfig.apiUrl;
93
+
94
+ if (typeof window !== 'undefined') {
95
+ if ((window as any).__LUX_API_URL__) {
96
+ return (window as any).__LUX_API_URL__;
97
+ }
98
+ if ((window as any).__LUX_LOCAL_MODE__) {
99
+ return 'http://localhost:3001';
100
+ }
101
+ }
102
+
103
+ if (typeof process !== 'undefined' && process.env) {
104
+ return process.env.NEXT_PUBLIC_LUX_API_URL || process.env.LUX_API_URL || 'https://v2.uselux.ai';
105
+ }
106
+
107
+ return 'https://v2.uselux.ai';
108
+ }
109
+
110
+ /**
111
+ * Get API key from config, window, or environment
112
+ */
113
+ function getApiKey(): string {
114
+ if (globalConfig.apiKey) return globalConfig.apiKey;
115
+
116
+ if (typeof window !== 'undefined' && (window as any).__LUX_API_KEY__) {
117
+ return (window as any).__LUX_API_KEY__;
118
+ }
119
+
120
+ if (typeof process !== 'undefined' && process.env) {
121
+ return process.env.NEXT_PUBLIC_LUX_API_KEY || process.env.LUX_API_KEY || '';
122
+ }
123
+
124
+ return '';
125
+ }
126
+
127
+ /**
128
+ * Get project ID from config, window, or environment
129
+ */
130
+ function getProjectId(): string {
131
+ if (globalConfig.projectId) return globalConfig.projectId;
132
+
133
+ if (typeof window !== 'undefined' && (window as any).__LUX_PROJECT_ID__) {
134
+ return (window as any).__LUX_PROJECT_ID__;
135
+ }
136
+
137
+ if (typeof process !== 'undefined' && process.env) {
138
+ return process.env.NEXT_PUBLIC_LUX_PROJECT_ID || process.env.LUX_PROJECT_ID || '';
139
+ }
140
+
141
+ return '';
142
+ }
143
+
144
+ // ============================================
145
+ // Core Upload Function
146
+ // ============================================
147
+
148
+ /**
149
+ * Upload a file to the knowledge bucket
150
+ *
151
+ * @param file - File or Blob to upload
152
+ * @param options - Upload options (folder, filename, progress callback)
153
+ * @returns Upload result with path and size
154
+ *
155
+ * @example
156
+ * const input = document.querySelector('input[type="file"]');
157
+ * const file = input.files[0];
158
+ * const result = await uploadToKnowledge(file, {
159
+ * folder: 'user-uploads',
160
+ * onProgress: (p) => console.log(`${p}% uploaded`),
161
+ * });
162
+ */
163
+ export async function uploadToKnowledge(
164
+ file: File | Blob,
165
+ options: UploadOptions = {}
166
+ ): Promise<UploadResult> {
167
+ const apiUrl = getApiUrl();
168
+ const apiKey = getApiKey();
169
+ const projectId = getProjectId();
170
+
171
+ if (!projectId) {
172
+ return {
173
+ success: false,
174
+ path: '',
175
+ size: 0,
176
+ error: 'Project ID not configured. Set LUX_PROJECT_ID or configure globally.',
177
+ };
178
+ }
179
+
180
+ const filename = options.filename || (file instanceof File ? file.name : 'upload');
181
+ const contentType = options.contentType || file.type || 'application/octet-stream';
182
+ const folder = options.folder || globalConfig.landingZone;
183
+ const deviceId = options.deviceId || globalConfig.deviceId || 'web-upload';
184
+
185
+ try {
186
+ // Step 1: Get presigned upload URL
187
+ const urlResponse = await fetch(`${apiUrl}/api/projects/${projectId}/knowledge/upload-url`, {
188
+ method: 'POST',
189
+ headers: {
190
+ 'Content-Type': 'application/json',
191
+ 'Authorization': `Bearer ${apiKey}`,
192
+ },
193
+ body: JSON.stringify({
194
+ path: filename,
195
+ contentType,
196
+ folder,
197
+ }),
198
+ });
199
+
200
+ if (!urlResponse.ok) {
201
+ const error = await urlResponse.json().catch(() => ({ error: 'Failed to get upload URL' }));
202
+ return {
203
+ success: false,
204
+ path: '',
205
+ size: 0,
206
+ error: error.error || `Failed to get upload URL: ${urlResponse.status}`,
207
+ };
208
+ }
209
+
210
+ const { uploadUrl, path } = await urlResponse.json();
211
+
212
+ // Step 2: Upload directly to R2
213
+ if (options.onProgress) {
214
+ // Use XMLHttpRequest for progress tracking
215
+ await uploadWithProgress(uploadUrl, file, contentType, options.onProgress);
216
+ } else {
217
+ // Use fetch for simpler uploads
218
+ const uploadResponse = await fetch(uploadUrl, {
219
+ method: 'PUT',
220
+ headers: {
221
+ 'Content-Type': contentType,
222
+ },
223
+ body: file,
224
+ });
225
+
226
+ if (!uploadResponse.ok) {
227
+ return {
228
+ success: false,
229
+ path: '',
230
+ size: 0,
231
+ error: `Upload failed: ${uploadResponse.status}`,
232
+ };
233
+ }
234
+ }
235
+
236
+ // Step 3: Confirm upload and trigger sync broadcast
237
+ const confirmResponse = await fetch(`${apiUrl}/api/projects/${projectId}/knowledge/confirm-upload`, {
238
+ method: 'POST',
239
+ headers: {
240
+ 'Content-Type': 'application/json',
241
+ 'Authorization': `Bearer ${apiKey}`,
242
+ 'X-Device-Id': deviceId,
243
+ },
244
+ body: JSON.stringify({ path }),
245
+ });
246
+
247
+ if (!confirmResponse.ok) {
248
+ // Upload succeeded but confirmation failed - file is still there
249
+ console.warn('[knowledge] Upload succeeded but confirmation failed');
250
+ }
251
+
252
+ const result = await confirmResponse.json();
253
+
254
+ return {
255
+ success: true,
256
+ path,
257
+ size: result.size || file.size,
258
+ };
259
+ } catch (error: any) {
260
+ return {
261
+ success: false,
262
+ path: '',
263
+ size: 0,
264
+ error: error.message || 'Upload failed',
265
+ };
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Upload with XMLHttpRequest for progress tracking
271
+ */
272
+ function uploadWithProgress(
273
+ url: string,
274
+ file: File | Blob,
275
+ contentType: string,
276
+ onProgress: (percent: number) => void
277
+ ): Promise<void> {
278
+ return new Promise((resolve, reject) => {
279
+ const xhr = new XMLHttpRequest();
280
+
281
+ xhr.upload.addEventListener('progress', (event) => {
282
+ if (event.lengthComputable) {
283
+ const percent = Math.round((event.loaded / event.total) * 100);
284
+ onProgress(percent);
285
+ }
286
+ });
287
+
288
+ xhr.addEventListener('load', () => {
289
+ if (xhr.status >= 200 && xhr.status < 300) {
290
+ onProgress(100);
291
+ resolve();
292
+ } else {
293
+ reject(new Error(`Upload failed: ${xhr.status}`));
294
+ }
295
+ });
296
+
297
+ xhr.addEventListener('error', () => {
298
+ reject(new Error('Upload failed: Network error'));
299
+ });
300
+
301
+ xhr.open('PUT', url);
302
+ xhr.setRequestHeader('Content-Type', contentType);
303
+ xhr.send(file);
304
+ });
305
+ }
306
+
307
+ // ============================================
308
+ // Batch Upload
309
+ // ============================================
310
+
311
+ /**
312
+ * Upload multiple files to knowledge
313
+ *
314
+ * @param files - Array of files to upload
315
+ * @param options - Upload options applied to all files
316
+ * @returns Array of upload results
317
+ *
318
+ * @example
319
+ * const input = document.querySelector('input[type="file"][multiple]');
320
+ * const results = await uploadMultiple(Array.from(input.files), {
321
+ * folder: 'batch-uploads',
322
+ * });
323
+ */
324
+ export async function uploadMultiple(
325
+ files: (File | Blob)[],
326
+ options: UploadOptions = {}
327
+ ): Promise<UploadResult[]> {
328
+ const results: UploadResult[] = [];
329
+
330
+ for (const file of files) {
331
+ const result = await uploadToKnowledge(file, options);
332
+ results.push(result);
333
+ }
334
+
335
+ return results;
336
+ }
337
+
338
+ // ============================================
339
+ // Factory Function
340
+ // ============================================
341
+
342
+ /**
343
+ * Create a configured knowledge uploader instance
344
+ *
345
+ * @param config - Configuration options
346
+ * @returns Uploader instance with bound config
347
+ *
348
+ * @example
349
+ * const knowledge = createKnowledgeUploader({
350
+ * landingZone: 'user-uploads',
351
+ * projectId: 'my-project-id',
352
+ * });
353
+ *
354
+ * // All uploads go to 'user-uploads/' folder
355
+ * await knowledge.upload(file);
356
+ * await knowledge.uploadMultiple(files);
357
+ */
358
+ export function createKnowledgeUploader(config: KnowledgeConfig = {}) {
359
+ const mergedConfig = { ...globalConfig, ...config };
360
+
361
+ return {
362
+ /**
363
+ * Upload a single file
364
+ */
365
+ upload: (file: File | Blob, options: UploadOptions = {}) => {
366
+ const prevConfig = globalConfig;
367
+ globalConfig = mergedConfig;
368
+ const result = uploadToKnowledge(file, options);
369
+ globalConfig = prevConfig;
370
+ return result;
371
+ },
372
+
373
+ /**
374
+ * Upload multiple files
375
+ */
376
+ uploadMultiple: (files: (File | Blob)[], options: UploadOptions = {}) => {
377
+ const prevConfig = globalConfig;
378
+ globalConfig = mergedConfig;
379
+ const result = uploadMultiple(files, options);
380
+ globalConfig = prevConfig;
381
+ return result;
382
+ },
383
+
384
+ /**
385
+ * Update configuration
386
+ */
387
+ configure: (newConfig: Partial<KnowledgeConfig>) => {
388
+ Object.assign(mergedConfig, newConfig);
389
+ },
390
+ };
391
+ }
392
+
393
+ // ============================================
394
+ // Convenience Exports
395
+ // ============================================
396
+
397
+ export const knowledge = {
398
+ upload: uploadToKnowledge,
399
+ uploadMultiple,
400
+ configure: configureKnowledge,
401
+ createUploader: createKnowledgeUploader,
402
+ };
403
+
404
+ export default knowledge;