eva4j 1.0.11 → 1.0.13

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.
Files changed (73) hide show
  1. package/AGENTS.md +441 -14
  2. package/DOMAIN_YAML_GUIDE.md +425 -21
  3. package/FUTURE_FEATURES.md +315 -115
  4. package/QUICK_REFERENCE.md +101 -153
  5. package/README.md +77 -70
  6. package/bin/eva4j.js +57 -1
  7. package/config/defaults.json +3 -0
  8. package/docs/commands/GENERATE_ENTITIES.md +662 -1968
  9. package/docs/commands/GENERATE_HTTP_EXCHANGE.md +274 -450
  10. package/docs/commands/GENERATE_KAFKA_EVENT.md +219 -498
  11. package/docs/commands/GENERATE_KAFKA_LISTENER.md +18 -18
  12. package/docs/commands/GENERATE_RECORD.md +335 -311
  13. package/docs/commands/GENERATE_TEMPORAL_ACTIVITY.md +174 -0
  14. package/docs/commands/GENERATE_TEMPORAL_FLOW.md +237 -0
  15. package/docs/commands/GENERATE_USECASE.md +216 -282
  16. package/docs/commands/INDEX.md +36 -7
  17. package/examples/doctor-evaluation.yaml +3 -3
  18. package/examples/domain-audit-complete.yaml +2 -2
  19. package/examples/domain-collections.yaml +2 -2
  20. package/examples/domain-ecommerce.yaml +2 -2
  21. package/examples/domain-events.yaml +201 -0
  22. package/examples/domain-field-visibility.yaml +11 -5
  23. package/examples/domain-multi-aggregate.yaml +12 -6
  24. package/examples/domain-one-to-many.yaml +1 -1
  25. package/examples/domain-one-to-one.yaml +1 -1
  26. package/examples/domain-secondary-onetomany.yaml +1 -1
  27. package/examples/domain-secondary-onetoone.yaml +1 -1
  28. package/examples/domain-simple.yaml +1 -1
  29. package/examples/domain-soft-delete.yaml +3 -3
  30. package/examples/domain-transitions.yaml +1 -1
  31. package/examples/domain-value-objects.yaml +1 -1
  32. package/package.json +2 -2
  33. package/src/commands/add-kafka-client.js +3 -1
  34. package/src/commands/add-temporal-client.js +286 -0
  35. package/src/commands/generate-entities.js +75 -4
  36. package/src/commands/generate-kafka-event.js +273 -89
  37. package/src/commands/generate-temporal-activity.js +228 -0
  38. package/src/commands/generate-temporal-flow.js +216 -0
  39. package/src/generators/module-generator.js +1 -0
  40. package/src/generators/shared-generator.js +26 -0
  41. package/src/utils/yaml-to-entity.js +93 -4
  42. package/templates/aggregate/AggregateRepository.java.ejs +3 -2
  43. package/templates/aggregate/AggregateRepositoryImpl.java.ejs +15 -7
  44. package/templates/aggregate/AggregateRoot.java.ejs +38 -2
  45. package/templates/aggregate/DomainEntity.java.ejs +6 -2
  46. package/templates/aggregate/DomainEventHandler.java.ejs +62 -0
  47. package/templates/aggregate/DomainEventRecord.java.ejs +50 -0
  48. package/templates/aggregate/JpaAggregateRoot.java.ejs +3 -1
  49. package/templates/aggregate/JpaEntity.java.ejs +3 -1
  50. package/templates/base/docker/kafka-services.yaml.ejs +2 -2
  51. package/templates/base/docker/temporal-services.yaml.ejs +29 -0
  52. package/templates/base/resources/parameters/develop/temporal.yaml.ejs +9 -0
  53. package/templates/base/resources/parameters/local/temporal.yaml.ejs +9 -0
  54. package/templates/base/resources/parameters/production/temporal.yaml.ejs +9 -0
  55. package/templates/base/resources/parameters/test/temporal.yaml.ejs +9 -0
  56. package/templates/base/root/AGENTS.md.ejs +916 -51
  57. package/templates/crud/Controller.java.ejs +36 -6
  58. package/templates/crud/ListQuery.java.ejs +6 -2
  59. package/templates/crud/ListQueryHandler.java.ejs +24 -10
  60. package/templates/crud/UpdateCommand.java.ejs +52 -0
  61. package/templates/crud/UpdateCommandHandler.java.ejs +105 -0
  62. package/templates/kafka-event/DomainEventHandlerMethod.ejs +1 -0
  63. package/templates/kafka-event/Event.java.ejs +23 -0
  64. package/templates/shared/application/dtos/PagedResponse.java.ejs +30 -0
  65. package/templates/shared/configurations/temporalConfig/TemporalConfig.java.ejs +104 -0
  66. package/templates/shared/domain/DomainEvent.java.ejs +40 -0
  67. package/templates/shared/interfaces/HeavyActivity.java.ejs +4 -0
  68. package/templates/shared/interfaces/LightActivity.java.ejs +4 -0
  69. package/templates/temporal-activity/ActivityImpl.java.ejs +14 -0
  70. package/templates/temporal-activity/ActivityInterface.java.ejs +11 -0
  71. package/templates/temporal-flow/WorkFlowImpl.java.ejs +64 -0
  72. package/templates/temporal-flow/WorkFlowInterface.java.ejs +19 -0
  73. package/templates/temporal-flow/WorkFlowService.java.ejs +49 -0
@@ -8,6 +8,7 @@ const ConfigManager = require('../utils/config-manager');
8
8
  const { isEva4jProject, moduleExists } = require('../utils/validator');
9
9
  const { toPackagePath, toPascalCase, toCamelCase, toSnakeCase, toKebabCase } = require('../utils/naming');
10
10
  const { renderAndWrite, renderTemplate } = require('../utils/template-engine');
11
+ const { parseDomainYaml } = require('../utils/yaml-to-entity');
11
12
 
12
13
  async function generateKafkaEventCommand(moduleName, eventName) {
13
14
  const projectDir = process.cwd();
@@ -51,127 +52,190 @@ async function generateKafkaEventCommand(moduleName, eventName) {
51
52
  process.exit(1);
52
53
  }
53
54
 
54
- // Prompt for event name if not provided
55
- if (!eventName) {
55
+ // Try to read domain events declared in domain.yaml for the module
56
+ let domainEventChoices = [];
57
+ let domainEventMap = {};
58
+ const domainYamlPath = path.join(projectDir, 'src', 'main', 'java', packagePath, moduleName, 'domain.yaml');
59
+ if (await fs.pathExists(domainYamlPath)) {
60
+ try {
61
+ const parsed = await parseDomainYaml(domainYamlPath, packageName, moduleName);
62
+ parsed.aggregates.forEach(agg => {
63
+ (agg.domainEvents || []).forEach(event => {
64
+ domainEventChoices.push({ name: `${event.name} (from ${agg.name} aggregate)`, value: event.name });
65
+ domainEventMap[event.name] = event;
66
+ });
67
+ });
68
+ } catch (_) {
69
+ // domain.yaml may not be parseable yet — silently fall back to free text
70
+ }
71
+ }
72
+
73
+ // ── Resolve list of event names to process ─────────────────────────────
74
+ let eventNames = [];
75
+
76
+ if (eventName) {
77
+ // Provided via CLI arg → single event
78
+ eventNames = [eventName];
79
+ } else if (domainEventChoices.length > 0) {
80
+ // Interactive multi-select from domain.yaml events
81
+ const choicesWithAll = [
82
+ { name: chalk.bold('★ All events'), value: '__all__' },
83
+ new inquirer.Separator(),
84
+ ...domainEventChoices,
85
+ new inquirer.Separator(),
86
+ { name: 'Custom name (free text)...', value: '__custom__' }
87
+ ];
88
+
89
+ const { selectedEvents } = await inquirer.prompt([
90
+ {
91
+ type: 'checkbox',
92
+ name: 'selectedEvents',
93
+ message: 'Select domain events to publish via Kafka (space to select, enter to confirm):',
94
+ choices: choicesWithAll,
95
+ validate: (input) => input.length > 0 ? true : 'Select at least one event'
96
+ }
97
+ ]);
98
+
99
+ if (selectedEvents.includes('__all__')) {
100
+ // Expand to every declared domain event
101
+ eventNames = domainEventChoices.map(c => c.value);
102
+ } else if (selectedEvents.includes('__custom__')) {
103
+ const { customName } = await inquirer.prompt([
104
+ {
105
+ type: 'input',
106
+ name: 'customName',
107
+ message: 'Enter event name:',
108
+ validate: (input) => input && input.trim() !== '' ? true : 'Event name cannot be empty'
109
+ }
110
+ ]);
111
+ eventNames = [customName];
112
+ } else {
113
+ eventNames = selectedEvents;
114
+ }
115
+ } else {
116
+ // No domain events in yaml → free-text fallback
56
117
  const nameAnswer = await inquirer.prompt([
57
118
  {
58
119
  type: 'input',
59
120
  name: 'eventName',
60
121
  message: 'Enter event name:',
61
- validate: (input) => {
62
- if (!input || input.trim() === '') {
63
- return 'Event name cannot be empty';
64
- }
65
- return true;
66
- }
122
+ validate: (input) => input && input.trim() !== '' ? true : 'Event name cannot be empty'
67
123
  }
68
124
  ]);
69
- eventName = nameAnswer.eventName;
125
+ eventNames = [nameAnswer.eventName];
70
126
  }
71
127
 
72
- // Normalize event name to PascalCase
73
- const normalizedEventName = toPascalCase(eventName);
74
- const eventClassName = normalizedEventName.endsWith('Event')
75
- ? normalizedEventName
76
- : `${normalizedEventName}Event`;
77
-
78
- // Check if event already exists
79
- const eventPath = path.join(projectDir, 'src', 'main', 'java', packagePath, moduleName, 'application', 'events', `${eventClassName}.java`);
80
- if (await fs.pathExists(eventPath)) {
81
- console.error(chalk.red(`❌ Event '${eventClassName}' already exists in module '${moduleName}'`));
82
- process.exit(1);
83
- }
84
-
85
- // Prompt for event configuration
128
+ // ── Shared configuration (applies to all selected events) ────────────────
86
129
  const answers = await inquirer.prompt([
87
130
  {
88
131
  type: 'number',
89
132
  name: 'partitions',
90
133
  message: 'Number of partitions:',
91
134
  default: 3,
92
- validate: (value) => {
93
- if (value < 1) return 'Partitions must be at least 1';
94
- return true;
95
- }
135
+ validate: (value) => value >= 1 ? true : 'Partitions must be at least 1'
96
136
  },
97
137
  {
98
138
  type: 'number',
99
139
  name: 'replicas',
100
140
  message: 'Number of replicas:',
101
141
  default: 1,
102
- validate: (value) => {
103
- if (value < 1) return 'Replicas must be at least 1';
104
- return true;
105
- }
142
+ validate: (value) => value >= 1 ? true : 'Replicas must be at least 1'
106
143
  }
107
144
  ]);
108
145
 
109
146
  const { partitions, replicas } = answers;
110
-
111
- const spinner = ora('Generating Kafka event...').start();
147
+ const isBatch = eventNames.length > 1;
148
+ const spinner = ora(`Generating ${isBatch ? `${eventNames.length} Kafka events` : 'Kafka event'}...`).start();
149
+ const results = [];
112
150
 
113
151
  try {
114
- // Generate property names
115
- const topicNameKebab = toKebabCase(eventName);
116
- const topicNameCamel = toCamelCase(eventName);
117
- const topicNameSnake = toSnakeCase(eventName).toUpperCase();
118
- const topicPropertyKey = topicNameKebab;
119
- const topicPropertyValue = topicNameSnake;
120
- const topicSpringProperty = `\${topics.${topicNameKebab}}`;
121
-
122
- const context = {
123
- packageName,
124
- moduleName,
125
- modulePascalCase: toPascalCase(moduleName),
126
- moduleCamelCase: toCamelCase(moduleName),
127
- kafkaMessageBrokerClassName: `${toPascalCase(moduleName)}KafkaMessageBroker`,
128
- eventClassName,
129
- topicNameSnake,
130
- topicNameKebab,
131
- topicNameCamel,
132
- topicPropertyKey,
133
- topicPropertyValue,
134
- topicSpringProperty,
135
- partitions,
136
- replicas
137
- };
138
-
139
- // 1. Generate Event Record
140
- spinner.text = 'Generating event record...';
141
- await generateEventRecord(projectDir, packagePath, context);
142
-
143
- // 2. Update kafka.yaml files
144
- spinner.text = 'Updating kafka.yaml configuration...';
145
- await updateKafkaYml(projectDir, topicPropertyKey, topicPropertyValue);
146
-
147
- // 3. Create/Update MessageBroker interface
148
- spinner.text = 'Updating MessageBroker interface...';
149
- await createOrUpdateMessageBroker(projectDir, packagePath, context);
150
-
151
- // 4. Create/Update KafkaMessageBroker implementation
152
- spinner.text = 'Updating KafkaMessageBroker implementation...';
153
- await createOrUpdateKafkaMessageBroker(projectDir, packagePath, context);
154
-
155
- // 5. Update KafkaConfig with NewTopic bean
156
- spinner.text = 'Updating KafkaConfig...';
157
- await updateKafkaConfig(projectDir, packagePath, context);
158
-
159
- spinner.succeed(chalk.green('Kafka event generated successfully! ✨'));
152
+ for (const name of eventNames) {
153
+ const normalizedName = toPascalCase(name);
154
+ const evtClassName = normalizedName.endsWith('Event') ? normalizedName : `${normalizedName}Event`;
155
+
156
+ // In batch mode skip already-existing events; in single mode abort
157
+ const evtPath = path.join(projectDir, 'src', 'main', 'java', packagePath, moduleName, 'application', 'events', `${evtClassName}.java`);
158
+ if (await fs.pathExists(evtPath)) {
159
+ if (isBatch) {
160
+ results.push({ name: evtClassName, skipped: true });
161
+ continue;
162
+ } else {
163
+ spinner.fail();
164
+ console.error(chalk.red(`❌ Event '${evtClassName}' already exists in module '${moduleName}'`));
165
+ process.exit(1);
166
+ }
167
+ }
160
168
 
169
+ const topicNameKebab = toKebabCase(name);
170
+ const topicNameCamel = toCamelCase(name);
171
+ const topicNameSnake = toSnakeCase(name).toUpperCase();
172
+ const topicSpringProperty = `\${topics.${topicNameKebab}}`;
173
+ const selectedDomainEvent = domainEventMap[normalizedName] || null;
174
+
175
+ const context = {
176
+ packageName,
177
+ moduleName,
178
+ modulePascalCase: toPascalCase(moduleName),
179
+ moduleCamelCase: toCamelCase(moduleName),
180
+ kafkaMessageBrokerClassName: `${toPascalCase(moduleName)}KafkaMessageBroker`,
181
+ eventClassName: evtClassName,
182
+ topicNameSnake,
183
+ topicNameKebab,
184
+ topicNameCamel,
185
+ topicPropertyKey: topicNameKebab,
186
+ topicPropertyValue: topicNameSnake,
187
+ topicSpringProperty,
188
+ partitions,
189
+ replicas,
190
+ eventFields: selectedDomainEvent ? selectedDomainEvent.fields : null
191
+ };
192
+
193
+ if (isBatch) spinner.text = `[${results.length + 1}/${eventNames.length}] Generating ${evtClassName}...`;
194
+
195
+ await generateSingleKafkaEvent(projectDir, packagePath, context);
196
+ results.push({ name: evtClassName, skipped: false, handlerUpdated: context._handlerUpdated });
197
+ }
198
+
199
+ const generated = results.filter(r => !r.skipped);
200
+ spinner.succeed(chalk.green(`${isBatch ? `${generated.length} Kafka events` : 'Kafka event'} generated successfully! ✨`));
201
+
202
+ const kafkaMessageBrokerClass = `${toPascalCase(moduleName)}KafkaMessageBroker`;
161
203
  console.log(chalk.blue('\n📦 Generated/Updated components:'));
162
- console.log(chalk.gray(` ├── ${moduleName}/application/events/${eventClassName}.java`));
204
+ results.forEach((r) => {
205
+ if (r.skipped) {
206
+ console.log(chalk.yellow(` ├── ${moduleName}/application/events/${r.name}.java (skipped — already exists)`));
207
+ } else {
208
+ console.log(chalk.gray(` ├── ${moduleName}/application/events/${r.name}.java`));
209
+ }
210
+ });
163
211
  console.log(chalk.gray(` ├── ${moduleName}/application/ports/MessageBroker.java`));
164
- console.log(chalk.gray(` ├── ${moduleName}/infrastructure/adapters/kafkaMessageBroker/${context.kafkaMessageBrokerClassName}.java`));
212
+ console.log(chalk.gray(` ├── ${moduleName}/infrastructure/adapters/kafkaMessageBroker/${kafkaMessageBrokerClass}.java`));
165
213
  console.log(chalk.gray(` ├── shared/configurations/kafkaConfig/KafkaConfig.java`));
214
+ if (results.some(r => r.handlerUpdated)) {
215
+ console.log(chalk.gray(` ├── ${moduleName}/application/usecases/*DomainEventHandler.java`));
216
+ }
166
217
  console.log(chalk.gray(' └── parameters/*/kafka.yaml (all environments)'));
167
-
168
- console.log(chalk.blue('\n✅ Kafka event configured successfully!'));
169
- console.log(chalk.white(`\n Event: ${eventClassName}`));
170
- console.log(chalk.white(` Topic: ${topicPropertyValue} (${topicNameKebab})`));
171
- console.log(chalk.white(` Partitions: ${partitions}`));
172
- console.log(chalk.white(` Replicas: ${replicas}`));
173
- console.log(chalk.gray('\n You can now inject MessageBroker in your services and call:'));
174
- console.log(chalk.gray(` messageBroker.publish${eventClassName}(event);\n`));
218
+
219
+ if (!isBatch && generated.length === 1) {
220
+ const r = generated[0];
221
+ const topicSnake = toSnakeCase(eventNames[0]).toUpperCase();
222
+ const topicKebab = toKebabCase(eventNames[0]);
223
+ console.log(chalk.blue('\n✅ Kafka event configured successfully!'));
224
+ console.log(chalk.white(`\n Event: ${r.name}`));
225
+ console.log(chalk.white(` Topic: ${topicSnake} (${topicKebab})`));
226
+ console.log(chalk.white(` Partitions: ${partitions}`));
227
+ console.log(chalk.white(` Replicas: ${replicas}`));
228
+ console.log(chalk.gray('\n You can now inject MessageBroker in your services and call:'));
229
+ console.log(chalk.gray(` messageBroker.publish${r.name}(event);\n`));
230
+ } else {
231
+ console.log(chalk.blue('\n✅ All Kafka events configured successfully!'));
232
+ console.log(chalk.white(`\n Partitions: ${partitions} | Replicas: ${replicas}`));
233
+ if (generated.length > 0) {
234
+ console.log(chalk.gray('\n Available MessageBroker methods:'));
235
+ generated.forEach(r => console.log(chalk.gray(` messageBroker.publish${r.name}(event);`)));
236
+ }
237
+ console.log('');
238
+ }
175
239
 
176
240
  } catch (error) {
177
241
  spinner.fail(chalk.red('Failed to generate Kafka event'));
@@ -183,6 +247,19 @@ async function generateKafkaEventCommand(moduleName, eventName) {
183
247
  }
184
248
  }
185
249
 
250
+ /**
251
+ * Run the full generation pipeline for a single event.
252
+ * Mutates context._handlerUpdated to signal whether the DomainEventHandler was wired.
253
+ */
254
+ async function generateSingleKafkaEvent(projectDir, packagePath, context) {
255
+ await generateEventRecord(projectDir, packagePath, context);
256
+ await updateKafkaYml(projectDir, context.topicPropertyKey, context.topicPropertyValue);
257
+ await createOrUpdateMessageBroker(projectDir, packagePath, context);
258
+ await createOrUpdateKafkaMessageBroker(projectDir, packagePath, context);
259
+ await updateKafkaConfig(projectDir, packagePath, context);
260
+ context._handlerUpdated = await updateDomainEventHandler(projectDir, packagePath, context);
261
+ }
262
+
186
263
  /**
187
264
  * Generate Event Record
188
265
  */
@@ -455,4 +532,111 @@ async function updateKafkaConfig(projectDir, packagePath, context) {
455
532
  await fs.writeFile(configPath, content, 'utf-8');
456
533
  }
457
534
 
535
+ /**
536
+ * Update DomainEventHandler: inject MessageBroker dependency and replace TODO with real mapping call.
537
+ * Returns true if the handler was found and updated, false if skipped.
538
+ */
539
+ async function updateDomainEventHandler(projectDir, packagePath, context) {
540
+ const handlerDir = path.join(
541
+ projectDir, 'src', 'main', 'java', packagePath,
542
+ context.moduleName, 'application', 'usecases'
543
+ );
544
+
545
+ if (!(await fs.pathExists(handlerDir))) {
546
+ console.log(chalk.yellow(`\n ⚠️ No usecases directory found — skipping handler update`));
547
+ console.log(chalk.gray(` Run 'eva4j g entities ${context.moduleName}' first to generate the DomainEventHandler`));
548
+ return false;
549
+ }
550
+
551
+ const files = await fs.readdir(handlerDir);
552
+ const handlerFile = files.find(f => f.endsWith('DomainEventHandler.java'));
553
+
554
+ if (!handlerFile) {
555
+ console.log(chalk.yellow(`\n ⚠️ DomainEventHandler not found in ${context.moduleName}/application/usecases/ — skipping`));
556
+ console.log(chalk.gray(` Run 'eva4j g entities ${context.moduleName}' first`));
557
+ return false;
558
+ }
559
+
560
+ const handlerPath = path.join(handlerDir, handlerFile);
561
+ const handlerClassName = handlerFile.replace('.java', '');
562
+ let content = await fs.readFile(handlerPath, 'utf-8');
563
+
564
+ // Idempotency: bail if the publish call is already there
565
+ if (content.includes(`publish${context.eventClassName}`)) {
566
+ return false;
567
+ }
568
+
569
+ // Compute domain event name by stripping 'Event' suffix from eventClassName
570
+ // e.g. OrderPlacedEvent → OrderPlaced
571
+ const domainEventName = context.eventClassName.endsWith('Event')
572
+ ? context.eventClassName.slice(0, -'Event'.length)
573
+ : context.eventClassName;
574
+
575
+ // Guard: the TODO comment must exist (generated by g entities)
576
+ if (!content.includes(`// TODO: handle ${domainEventName}`)) {
577
+ console.log(chalk.yellow(`\n ⚠️ No TODO handler found for '${domainEventName}' in ${handlerFile} — skipping`));
578
+ return false;
579
+ }
580
+
581
+ // 1. Inject import for the application-layer event record
582
+ const eventImport = `import ${context.packageName}.${context.moduleName}.application.events.${context.eventClassName};`;
583
+ if (!content.includes(eventImport)) {
584
+ content = injectImportIntoFile(content, eventImport);
585
+ }
586
+
587
+ // 2. Inject import for the MessageBroker port
588
+ const brokerImport = `import ${context.packageName}.${context.moduleName}.application.ports.MessageBroker;`;
589
+ if (!content.includes(brokerImport)) {
590
+ content = injectImportIntoFile(content, brokerImport);
591
+ }
592
+
593
+ // 3. Inject field + constructor if MessageBroker is not yet present
594
+ if (!content.includes('private final MessageBroker messageBroker;')) {
595
+ const classPattern = /(public\s+class\s+\w+DomainEventHandler\s*\{\s*\n)/;
596
+ const classMatch = content.match(classPattern);
597
+ if (classMatch) {
598
+ const insertPos = classMatch.index + classMatch[0].length;
599
+ const fieldAndCtor =
600
+ `\n private final MessageBroker messageBroker;\n` +
601
+ `\n public ${handlerClassName}(MessageBroker messageBroker) {\n` +
602
+ ` this.messageBroker = messageBroker;\n` +
603
+ ` }\n`;
604
+ content = content.slice(0, insertPos) + fieldAndCtor + content.slice(insertPos);
605
+ }
606
+ }
607
+
608
+ // 4. Render the mapping call and replace the TODO block
609
+ const templatePath = path.join(__dirname, '..', '..', 'templates', 'kafka-event', 'DomainEventHandlerMethod.ejs');
610
+ const mappingLine = await renderTemplate(templatePath, { ...context, domainEventFields: context.eventFields });
611
+
612
+ const todoRegex = new RegExp(
613
+ `([ \\t]*\/\/ TODO: handle ${domainEventName}[^\\n]*\\n)(?:[ \\t]*\/\/[^\\n]*\\n)*`
614
+ );
615
+ content = content.replace(todoRegex, ` ${mappingLine.trim()}\n`);
616
+
617
+ await fs.writeFile(handlerPath, content, 'utf-8');
618
+ return true;
619
+ }
620
+
621
+ /**
622
+ * Injects an import statement after the last existing import, or after the package declaration.
623
+ */
624
+ function injectImportIntoFile(content, importStatement) {
625
+ const packageMatch = content.match(/(package\s+[\w.]+;\s*\n)/);
626
+ if (!packageMatch) return content;
627
+
628
+ const hasImports = /import\s+[\w.]+;/.test(content);
629
+ if (hasImports) {
630
+ const imports = content.matchAll(/import\s+[\w.]+;\s*\n/g);
631
+ let lastImportEnd = packageMatch.index + packageMatch[0].length;
632
+ for (const match of imports) {
633
+ lastImportEnd = match.index + match[0].length;
634
+ }
635
+ return content.slice(0, lastImportEnd) + importStatement + '\n' + content.slice(lastImportEnd);
636
+ } else {
637
+ const insertPos = packageMatch.index + packageMatch[0].length;
638
+ return content.slice(0, insertPos) + '\n' + importStatement + '\n' + content.slice(insertPos);
639
+ }
640
+ }
641
+
458
642
  module.exports = generateKafkaEventCommand;
@@ -0,0 +1,228 @@
1
+ const inquirer = require('inquirer');
2
+ const ora = require('ora');
3
+ const chalk = require('chalk');
4
+ const path = require('path');
5
+ const fs = require('fs-extra');
6
+ const ConfigManager = require('../utils/config-manager');
7
+ const { isEva4jProject, moduleExists } = require('../utils/validator');
8
+ const { toPackagePath, toPascalCase } = require('../utils/naming');
9
+ const { renderAndWrite } = require('../utils/template-engine');
10
+ const ChecksumManager = require('../utils/checksum-manager');
11
+
12
+ async function generateTemporalActivityCommand(moduleName, activityName, options = {}) {
13
+ const projectDir = process.cwd();
14
+
15
+ if (!(await isEva4jProject(projectDir))) {
16
+ console.error(chalk.red('❌ Not in an eva4j project directory'));
17
+ console.error(chalk.gray('Run this command inside a project created with eva4j'));
18
+ process.exit(1);
19
+ }
20
+
21
+ const configManager = new ConfigManager(projectDir);
22
+
23
+ if (!(await configManager.featureExists('temporal'))) {
24
+ console.error(chalk.red('❌ Temporal client is not installed in this project'));
25
+ console.error(chalk.gray('Install Temporal first using: eva add temporal-client'));
26
+ process.exit(1);
27
+ }
28
+
29
+ if (!moduleName) {
30
+ const answer = await inquirer.prompt([{
31
+ type: 'input',
32
+ name: 'moduleName',
33
+ message: 'Module name:',
34
+ validate: (v) => (v.trim() ? true : 'Module name is required'),
35
+ }]);
36
+ moduleName = answer.moduleName.trim();
37
+ }
38
+
39
+ const projectConfig = await configManager.loadProjectConfig();
40
+ if (!projectConfig) {
41
+ console.error(chalk.red('❌ Could not load project configuration'));
42
+ console.error(chalk.gray('Make sure .eva4j.json exists in the project root'));
43
+ process.exit(1);
44
+ }
45
+
46
+ const { packageName } = projectConfig;
47
+ const packagePath = toPackagePath(packageName);
48
+
49
+ if (!(await configManager.moduleExists(moduleName))) {
50
+ console.error(chalk.red(`❌ Module '${moduleName}' does not exist`));
51
+ console.error(chalk.gray(`Create it first using: eva add module ${moduleName}`));
52
+ process.exit(1);
53
+ }
54
+
55
+ if (!(await moduleExists(projectDir, packagePath, moduleName))) {
56
+ console.error(chalk.red(`❌ Module directory for '${moduleName}' not found`));
57
+ process.exit(1);
58
+ }
59
+
60
+ // 1. Prompt activity name
61
+ if (!activityName) {
62
+ const answer = await inquirer.prompt([{
63
+ type: 'input',
64
+ name: 'activityName',
65
+ message: 'Activity name (e.g. register-order, ValidatePayment):',
66
+ validate: (v) => (v.trim() ? true : 'Activity name is required'),
67
+ }]);
68
+ activityName = answer.activityName.trim();
69
+ }
70
+
71
+ const activityPascalCase = toPascalCase(activityName);
72
+
73
+ // 2. Prompt activity category
74
+ const { activityCategory } = await inquirer.prompt([{
75
+ type: 'list',
76
+ name: 'activityCategory',
77
+ message: 'Activity category:',
78
+ choices: [
79
+ { name: 'LightActivity (fast, low-resource tasks)', value: 'LightActivity' },
80
+ { name: 'HeavyActivity (long-running, resource-intensive tasks)', value: 'HeavyActivity' },
81
+ ],
82
+ }]);
83
+
84
+ // 3. Discover existing workflows across all modules
85
+ const javaRoot = path.join(projectDir, 'src', 'main', 'java', packagePath);
86
+ const workflows = await findExistingWorkflows(javaRoot);
87
+
88
+ if (workflows.length === 0) {
89
+ console.error(chalk.red('❌ No workflows found in this project'));
90
+ console.error(chalk.gray('Create a workflow first using: eva generate temporal-flow <module>'));
91
+ process.exit(1);
92
+ }
93
+
94
+ const { selectedWorkflow } = await inquirer.prompt([{
95
+ type: 'list',
96
+ name: 'selectedWorkflow',
97
+ message: 'Register activity in workflow:',
98
+ choices: workflows.map((w) => ({ name: `${w.moduleName} / ${w.implClass}`, value: w })),
99
+ }]);
100
+
101
+ const moduleBasePath = path.join(projectDir, 'src', 'main', 'java', packagePath, moduleName);
102
+ const checksumManager = new ChecksumManager(moduleBasePath);
103
+ await checksumManager.load();
104
+
105
+ const spinner = ora(`Generating ${activityPascalCase}Activity...`).start();
106
+
107
+ try {
108
+ const context = { packageName, moduleName, activityPascalCase, activityCategory };
109
+ const templatesDir = path.join(__dirname, '..', '..', 'templates', 'temporal-activity');
110
+ const writeOptions = { force: options.force, checksumManager };
111
+
112
+ // 4. Generate ActivityInterface in application/ports/
113
+ spinner.text = `Generating ${activityPascalCase}Activity interface...`;
114
+ await renderAndWrite(
115
+ path.join(templatesDir, 'ActivityInterface.java.ejs'),
116
+ path.join(moduleBasePath, 'application', 'ports', `${activityPascalCase}Activity.java`),
117
+ context,
118
+ writeOptions
119
+ );
120
+
121
+ // 5. Generate ActivityImpl in infrastructure/adapters/activities/
122
+ spinner.text = `Generating ${activityPascalCase}ActivityImpl...`;
123
+ await renderAndWrite(
124
+ path.join(templatesDir, 'ActivityImpl.java.ejs'),
125
+ path.join(moduleBasePath, 'infrastructure', 'adapters', 'activities', `${activityPascalCase}ActivityImpl.java`),
126
+ context,
127
+ writeOptions
128
+ );
129
+
130
+ // 6. Register activity stub in selected WorkFlowImpl
131
+ spinner.text = `Registering activity in ${selectedWorkflow.implClass}...`;
132
+ await registerActivityInWorkflow(
133
+ selectedWorkflow.filePath,
134
+ packageName,
135
+ moduleName,
136
+ activityPascalCase,
137
+ activityCategory
138
+ );
139
+
140
+ spinner.succeed(chalk.green(`✅ ${activityPascalCase}Activity generated successfully`));
141
+
142
+ console.log(chalk.blue('\n📁 Generated files:'));
143
+ console.log(chalk.gray(` ${moduleName}/application/ports/${activityPascalCase}Activity.java`));
144
+ console.log(chalk.gray(` ${moduleName}/infrastructure/adapters/activities/${activityPascalCase}ActivityImpl.java`));
145
+ console.log(chalk.blue('\n📝 Updated files:'));
146
+ console.log(chalk.gray(` ${selectedWorkflow.moduleName}/application/usecases/${selectedWorkflow.implClass}.java`));
147
+
148
+ await checksumManager.save();
149
+ } catch (error) {
150
+ spinner.fail(chalk.red('Failed to generate temporal activity'));
151
+ console.error(chalk.red(error.message));
152
+ process.exit(1);
153
+ }
154
+ }
155
+
156
+ async function findExistingWorkflows(javaRoot) {
157
+ const results = [];
158
+ if (!(await fs.pathExists(javaRoot))) return results;
159
+
160
+ const modules = await fs.readdir(javaRoot);
161
+ for (const mod of modules) {
162
+ const usecasesDir = path.join(javaRoot, mod, 'application', 'usecases');
163
+ if (!(await fs.pathExists(usecasesDir))) continue;
164
+ const files = await fs.readdir(usecasesDir);
165
+ for (const file of files) {
166
+ if (file.endsWith('WorkFlowImpl.java')) {
167
+ results.push({
168
+ moduleName: mod,
169
+ implClass: file.replace('.java', ''),
170
+ filePath: path.join(usecasesDir, file),
171
+ });
172
+ }
173
+ }
174
+ }
175
+ return results;
176
+ }
177
+
178
+ async function registerActivityInWorkflow(workflowImplPath, packageName, moduleName, activityPascalCase, activityCategory) {
179
+ if (!(await fs.pathExists(workflowImplPath))) return;
180
+
181
+ let content = await fs.readFile(workflowImplPath, 'utf-8');
182
+
183
+ const activityInterface = `${activityPascalCase}Activity`;
184
+ const activityField = `${activityPascalCase.charAt(0).toLowerCase()}${activityPascalCase.slice(1)}Activity`;
185
+ const importLine = `import ${packageName}.${moduleName}.application.ports.${activityInterface};`;
186
+ const optionsVar = activityCategory === 'HeavyActivity' ? 'heavyActivityOptions' : 'lightActivityOptions';
187
+ const fieldLine = ` private final ${activityInterface} ${activityField} = Workflow.newActivityStub(${activityInterface}.class, ${optionsVar});`;
188
+
189
+ // Skip if already registered
190
+ if (content.includes(fieldLine)) return;
191
+
192
+ // Add import after last import line
193
+ if (!content.includes(importLine)) {
194
+ const allImports = [...content.matchAll(/^import .+;/gm)];
195
+ if (allImports.length > 0) {
196
+ const lastImport = allImports[allImports.length - 1];
197
+ const insertPos = lastImport.index + lastImport[0].length;
198
+ content = content.slice(0, insertPos) + '\n' + importLine + content.slice(insertPos);
199
+ }
200
+ }
201
+
202
+ // Insert field after last activityOptions block (or append after last existing stub)
203
+ const activityOptionsRegex = /(private final ActivityOptions heavyActivityOptions = ActivityOptions\.newBuilder\(\)[\s\S]*?\.build\(\)\s*\)\.build\(\);)/;
204
+ if (content.includes('//register activities')) {
205
+ // Append after the last existing activity stub
206
+ const lastStubRegex = /(private final \w+Activity \w+Activity = Workflow\.newActivityStub\([^;]+;)/g;
207
+ const allStubs = [...content.matchAll(lastStubRegex)];
208
+ if (allStubs.length > 0) {
209
+ const lastStub = allStubs[allStubs.length - 1];
210
+ const insertPos = lastStub.index + lastStub[0].length;
211
+ content = content.slice(0, insertPos) + '\n' + fieldLine + content.slice(insertPos);
212
+ }
213
+ } else if (activityOptionsRegex.test(content)) {
214
+ content = content.replace(activityOptionsRegex, (match) => {
215
+ return match + '\n\n //register activities\n' + fieldLine;
216
+ });
217
+ } else {
218
+ // Fallback: insert before the first @Override
219
+ content = content.replace(
220
+ /( {4}@Override)/,
221
+ ` //register activities\n${fieldLine}\n\n$1`
222
+ );
223
+ }
224
+
225
+ await fs.writeFile(workflowImplPath, content, 'utf-8');
226
+ }
227
+
228
+ module.exports = generateTemporalActivityCommand;