dhti-cli 0.8.0 → 1.1.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.
@@ -1,13 +1,17 @@
1
1
  import { Args, Command, Flags } from '@oclif/core';
2
2
  import chalk from 'chalk';
3
+ // eslint-disable-next-line import/default
4
+ import yaml from 'js-yaml';
3
5
  import { exec } from 'node:child_process';
4
6
  import fs from 'node:fs';
5
7
  import os from 'node:os';
6
8
  import path from 'node:path';
7
9
  import { fileURLToPath } from 'node:url';
10
+ import { promisify } from 'node:util';
11
+ const execAsync = promisify(exec);
8
12
  export default class Elixir extends Command {
9
13
  static args = {
10
- op: Args.string({ description: 'Operation to perform (install, uninstall or dev)' }),
14
+ op: Args.string({ description: 'Operation to perform (init, install, uninstall, dev or start)' }),
11
15
  };
12
16
  static description = 'Install or uninstall elixirs to create a Docker image';
13
17
  static examples = ['<%= config.bin %> <%= command.id %>'];
@@ -23,6 +27,15 @@ export default class Elixir extends Command {
23
27
  default: false,
24
28
  description: 'Show what changes would be made without actually making them',
25
29
  }),
30
+ elixir: Flags.string({
31
+ char: 'e',
32
+ description: 'Elixir endpoint URL',
33
+ }),
34
+ fhir: Flags.string({
35
+ char: 'f',
36
+ default: 'http://hapi.fhir.org/baseR4',
37
+ description: 'FHIR endpoint URL',
38
+ }),
26
39
  git: Flags.string({ char: 'g', default: 'none', description: 'Github repository to install' }),
27
40
  local: Flags.string({ char: 'l', default: 'none', description: 'Local directory to install from' }),
28
41
  name: Flags.string({ char: 'n', description: 'Name of the elixir' }),
@@ -50,6 +63,202 @@ export default class Elixir extends Command {
50
63
  const __filename = fileURLToPath(import.meta.url);
51
64
  const __dirname = path.dirname(__filename);
52
65
  const RESOURCES_DIR = path.resolve(__dirname, '../resources');
66
+ // Handle init operation
67
+ if (args.op === 'init') {
68
+ // Validate required flags
69
+ if (!flags.workdir) {
70
+ console.error(chalk.red('Error: workdir flag is required for init operation'));
71
+ this.exit(1);
72
+ }
73
+ if (!flags.name) {
74
+ console.error(chalk.red('Error: name flag is required for init operation'));
75
+ this.exit(1);
76
+ }
77
+ const targetDir = path.join(flags.workdir, 'dhti-elixir');
78
+ if (flags['dry-run']) {
79
+ console.log(chalk.yellow('[DRY RUN] Would execute init operation:'));
80
+ console.log(chalk.cyan(` npx degit dermatologist/dhti-elixir ${targetDir}`));
81
+ console.log(chalk.cyan(` Copy ${targetDir}/packages/starter to ${targetDir}/packages/${flags.name}`));
82
+ return;
83
+ }
84
+ try {
85
+ // Run npx degit to clone the dhti-elixir template
86
+ console.log(chalk.blue(`Initializing DHTI elixir template in ${targetDir}...`));
87
+ const degitCommand = `npx degit dermatologist/dhti-elixir ${targetDir}`;
88
+ await execAsync(degitCommand);
89
+ console.log(chalk.green('✓ DHTI elixir template cloned successfully'));
90
+ // Copy packages/starter subdirectory to packages/<name>
91
+ const simpleChatSource = path.join(targetDir, 'packages', 'starter');
92
+ const targetPackageDir = path.join(targetDir, 'packages', flags.name);
93
+ if (fs.existsSync(simpleChatSource)) {
94
+ console.log(chalk.blue(`Copying starter to packages/${flags.name}...`));
95
+ fs.cpSync(simpleChatSource, targetPackageDir, { recursive: true });
96
+ console.log(chalk.green(`✓ starter copied to packages/${flags.name}`));
97
+ }
98
+ else {
99
+ console.log(chalk.yellow(`Warning: starter not found at ${simpleChatSource}`));
100
+ }
101
+ console.log(chalk.green(`\n✓ Initialization complete! Your elixir workspace is ready at ${targetDir}`));
102
+ console.log(chalk.blue(`\nNext steps:`));
103
+ console.log(chalk.cyan(` 1. cd ${targetDir}`));
104
+ console.log(chalk.cyan(` 2. Follow the README.md for development instructions`));
105
+ }
106
+ catch (error) {
107
+ console.error(chalk.red('Error during initialization:'), error);
108
+ this.exit(1);
109
+ }
110
+ return;
111
+ }
112
+ // Handle start operation
113
+ if (args.op === 'start') {
114
+ // Determine the elixir endpoint URL
115
+ let elixirUrl = flags.elixir;
116
+ if (!elixirUrl) {
117
+ // If --elixir is not provided, construct it from --name
118
+ if (!flags.name) {
119
+ console.error(chalk.red('Error: Either --elixir or --name flag must be provided for start operation'));
120
+ this.exit(1);
121
+ }
122
+ const nameWithUnderscores = flags.name.replaceAll('-', '_');
123
+ elixirUrl = `http://localhost:8001/langserve/${nameWithUnderscores}/cds-services`;
124
+ }
125
+ const sandboxDir = path.join(flags.workdir, 'cds-hooks-sandbox');
126
+ if (flags['dry-run']) {
127
+ const directoryExists = fs.existsSync(sandboxDir);
128
+ console.log(chalk.yellow('[DRY RUN] Would execute start operation:'));
129
+ if (directoryExists) {
130
+ console.log(chalk.cyan(` [SKIP] Directory already exists: ${sandboxDir}`));
131
+ }
132
+ else {
133
+ console.log(chalk.cyan(` npx degit dermatologist/cds-hooks-sandbox ${sandboxDir}`));
134
+ console.log(chalk.cyan(` cd ${sandboxDir}`));
135
+ console.log(chalk.cyan(` yarn install`));
136
+ }
137
+ console.log(chalk.cyan(` yarn dhti ${elixirUrl} ${flags.fhir}`));
138
+ console.log(chalk.cyan(` Update docker-compose.yml FHIR_BASE_URL=${flags.fhir}`));
139
+ console.log(chalk.cyan(` docker restart ${flags.container}`));
140
+ console.log(chalk.cyan(` yarn dev`));
141
+ return;
142
+ }
143
+ try {
144
+ // Check if the directory already exists
145
+ const directoryExists = fs.existsSync(sandboxDir);
146
+ if (directoryExists) {
147
+ console.log(chalk.blue(`Using existing CDS Hooks Sandbox at ${sandboxDir}...`));
148
+ console.log(chalk.green('✓ Skipped clone and install (directory already exists)'));
149
+ }
150
+ else {
151
+ // Clone the cds-hooks-sandbox repository
152
+ console.log(chalk.blue(`Cloning CDS Hooks Sandbox to ${sandboxDir}...`));
153
+ const degitCommand = `npx degit dermatologist/cds-hooks-sandbox ${sandboxDir}`;
154
+ await execAsync(degitCommand);
155
+ console.log(chalk.green('✓ CDS Hooks Sandbox cloned successfully'));
156
+ // Install dependencies
157
+ console.log(chalk.blue('Installing dependencies...'));
158
+ const installCommand = `cd ${sandboxDir} && yarn install`;
159
+ const { stderr: installError } = await execAsync(installCommand);
160
+ if (installError && !installError.includes('warning')) {
161
+ console.error(chalk.yellow(`Installation warnings: ${installError}`));
162
+ }
163
+ console.log(chalk.green('✓ Dependencies installed successfully'));
164
+ }
165
+ // Configure dhti endpoints
166
+ console.log(chalk.blue('Configuring DHTI endpoints...'));
167
+ const dhtiCommand = `cd ${sandboxDir} && yarn dhti ${elixirUrl} ${flags.fhir}`;
168
+ const { stderr: dhtiError } = await execAsync(dhtiCommand);
169
+ if (dhtiError && !dhtiError.includes('warning')) {
170
+ console.error(chalk.yellow(`Configuration warnings: ${dhtiError}`));
171
+ }
172
+ console.log(chalk.green('✓ DHTI endpoints configured successfully'));
173
+ // Configure Docker container with FHIR_BASE_URL environment variable
174
+ console.log(chalk.blue('Setting up Docker container environment...'));
175
+ try {
176
+ // Update the docker-compose.yml file with the new FHIR_BASE_URL
177
+ const dockerComposeFile = path.join(flags.workdir, 'docker-compose.yml');
178
+ if (fs.existsSync(dockerComposeFile)) {
179
+ const composeContent = fs.readFileSync(dockerComposeFile, 'utf8');
180
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
181
+ const compose = yaml.load(composeContent);
182
+ if (compose.services && compose.services.langserve) {
183
+ if (!compose.services.langserve.environment) {
184
+ compose.services.langserve.environment = [];
185
+ }
186
+ // Update or add the FHIR_BASE_URL environment variable
187
+ const envArray = compose.services.langserve.environment;
188
+ const fhirIndex = envArray.findIndex((env) => {
189
+ if (typeof env === 'string') {
190
+ return env.startsWith('FHIR_BASE_URL=');
191
+ }
192
+ return typeof env === 'object' && env !== null && 'FHIR_BASE_URL' in env;
193
+ });
194
+ if (fhirIndex >= 0) {
195
+ // Update existing environment variable
196
+ const existingEnv = envArray[fhirIndex];
197
+ if (typeof existingEnv === 'string') {
198
+ envArray[fhirIndex] = `FHIR_BASE_URL=${flags.fhir}`;
199
+ }
200
+ else if (typeof existingEnv === 'object' && existingEnv !== null) {
201
+ existingEnv.FHIR_BASE_URL = flags.fhir;
202
+ }
203
+ }
204
+ else {
205
+ // Add new environment variable
206
+ envArray.push(`FHIR_BASE_URL=${flags.fhir}`);
207
+ }
208
+ const updatedCompose = yaml.dump(compose);
209
+ fs.writeFileSync(dockerComposeFile, updatedCompose);
210
+ console.log(chalk.green(`✓ docker-compose.yml updated with FHIR_BASE_URL=${flags.fhir}`));
211
+ }
212
+ else {
213
+ console.warn(chalk.yellow('⚠ Warning: langserve service not found in docker-compose.yml'));
214
+ }
215
+ }
216
+ else {
217
+ console.warn(chalk.yellow(`⚠ Warning: docker-compose.yml not found at ${dockerComposeFile}`));
218
+ }
219
+ // Restart the container to apply the new environment variables
220
+ const upCommand = `docker compose up -d`; // Docker Compose is smart enough to re-create only the services where the configuration, including environment variables, has changed
221
+ await execAsync(upCommand, { cwd: flags.workdir });
222
+ console.log(chalk.green(`✓ Docker container ${flags.container} restarted (compose up -d) successfully`));
223
+ }
224
+ catch (error) {
225
+ const err = error;
226
+ if (err.message?.includes('No such container')) {
227
+ console.warn(chalk.yellow(`⚠ Warning: Docker container ${flags.container} not found. Skipping container restart.`));
228
+ }
229
+ else {
230
+ console.error(chalk.red(`Error setting up Docker container: ${err.message}`));
231
+ this.exit(1);
232
+ }
233
+ }
234
+ // Start the development server
235
+ console.log(chalk.blue('Starting development server...'));
236
+ const { spawn } = await import('node:child_process');
237
+ const devCommand = `yarn dhti ${elixirUrl} ${flags.fhir} && yarn dev`;
238
+ try {
239
+ const child = spawn(devCommand, { cwd: sandboxDir, shell: true, stdio: 'inherit' });
240
+ await new Promise((resolve, reject) => {
241
+ child.on('exit', (code) => {
242
+ if (code === 0)
243
+ resolve(undefined);
244
+ else
245
+ reject(new Error(`Dev server exited with code ${code}`));
246
+ });
247
+ child.on('error', reject);
248
+ });
249
+ }
250
+ catch (error) {
251
+ const err = error;
252
+ console.error(chalk.red('Error starting development server:'), err.message);
253
+ this.exit(1);
254
+ }
255
+ }
256
+ catch (error) {
257
+ console.error(chalk.red('Error during start operation:'), error);
258
+ this.exit(1);
259
+ }
260
+ return;
261
+ }
53
262
  if (!flags.name) {
54
263
  console.log('Please provide a name for the elixir');
55
264
  this.exit(1);
@@ -90,43 +299,46 @@ export default class Elixir extends Command {
90
299
  }
91
300
  return;
92
301
  }
93
- // Create a directory to install the elixir
94
- if (!fs.existsSync(`${flags.workdir}/elixir`)) {
302
+ // Create a directory to install the elixir (only on first install)
303
+ const elixirDir = `${flags.workdir}/elixir`;
304
+ const isFirstInstall = !fs.existsSync(elixirDir);
305
+ if (isFirstInstall) {
95
306
  if (flags['dry-run']) {
96
- console.log(chalk.yellow(`[DRY RUN] Would create directory: ${flags.workdir}/elixir`));
307
+ console.log(chalk.yellow(`[DRY RUN] Would create directory: ${elixirDir}`));
308
+ console.log(chalk.yellow(`[DRY RUN] Would copy resources from ${RESOURCES_DIR}/genai to ${elixirDir}`));
97
309
  }
98
310
  else {
99
- fs.mkdirSync(`${flags.workdir}/elixir`);
311
+ fs.mkdirSync(elixirDir);
312
+ fs.cpSync(path.join(RESOURCES_DIR, 'genai'), elixirDir, { recursive: true });
313
+ console.log(chalk.blue(`✓ Initialized elixir directory at ${elixirDir}`));
100
314
  }
101
315
  }
102
- if (flags['dry-run']) {
103
- console.log(chalk.yellow(`[DRY RUN] Would copy resources from ${RESOURCES_DIR}/genai to ${flags.workdir}/elixir`));
316
+ else if (args.op === 'install') {
317
+ console.log(chalk.blue(`Using existing elixir directory at ${elixirDir}`));
104
318
  }
105
- else {
106
- fs.cpSync(path.join(RESOURCES_DIR, 'genai'), `${flags.workdir}/elixir`, { recursive: true });
107
- }
108
- // if whl is not none, copy the whl file to thee whl directory
319
+ // if whl is not none, copy the whl file to the whl directory
109
320
  if (flags.whl !== 'none') {
110
- if (!fs.existsSync(`${flags.workdir}/elixir/whl/`)) {
321
+ const whlDir = `${elixirDir}/whl/`;
322
+ if (!fs.existsSync(whlDir)) {
111
323
  if (flags['dry-run']) {
112
- console.log(chalk.yellow(`[DRY RUN] Would create directory: ${flags.workdir}/whl/`));
324
+ console.log(chalk.yellow(`[DRY RUN] Would create directory: ${whlDir}`));
113
325
  }
114
326
  else {
115
- fs.mkdirSync(`${flags.workdir}/whl/`);
327
+ fs.mkdirSync(whlDir);
116
328
  }
117
329
  }
118
330
  if (flags['dry-run']) {
119
- console.log(chalk.yellow(`[DRY RUN] Would copy ${flags.whl} to ${flags.workdir}/elixir/whl/${path.basename(flags.whl)}`));
120
- console.log(chalk.cyan('[DRY RUN] Installing elixir from whl file. Please modify boostrap.py file if needed'));
331
+ console.log(chalk.yellow(`[DRY RUN] Would copy ${flags.whl} to ${whlDir}${path.basename(flags.whl)}`));
332
+ console.log(chalk.cyan('[DRY RUN] Installing elixir from whl file. Please modify bootstrap.py file if needed'));
121
333
  }
122
334
  else {
123
- fs.cpSync(flags.whl, `${flags.workdir}/elixir/whl/${path.basename(flags.whl)}`);
124
- console.log('Installing elixir from whl file. Please modify boostrap.py file if needed');
335
+ fs.cpSync(flags.whl, `${whlDir}${path.basename(flags.whl)}`);
336
+ console.log('Installing elixir from whl file. Please modify bootstrap.py file if needed');
125
337
  }
126
338
  }
127
339
  // Install the elixir from git adding to the pyproject.toml file
128
- let pyproject = flags['dry-run'] ? '' : fs.readFileSync(`${flags.workdir}/elixir/pyproject.toml`, 'utf8');
129
- const originalServer = flags['dry-run'] ? '' : fs.readFileSync(`${flags.workdir}/elixir/app/server.py`, 'utf8');
340
+ // Always read from the current state, not the template
341
+ let pyproject = flags['dry-run'] ? '' : fs.readFileSync(`${elixirDir}/pyproject.toml`, 'utf8');
130
342
  let lineToAdd = '';
131
343
  if (flags.whl !== 'none') {
132
344
  lineToAdd = `${flags.name} = { file = "whl/${path.basename(flags.whl)}" }`;
@@ -157,11 +369,49 @@ export default class Elixir extends Command {
157
369
  }
158
370
  lineToAdd = `${flags.name} = { path = "${absolutePath}" }`;
159
371
  }
372
+ // Helper function to add dependency to pyproject.toml
373
+ const addDependencyToPyproject = (content, depName) => {
374
+ // Check if dependency already exists
375
+ if (content.includes(`"${depName}"`)) {
376
+ return content;
377
+ }
378
+ // Add to dependencies array
379
+ return content.replace('dependencies = [', `dependencies = [\n"${depName}",`);
380
+ };
381
+ // Helper function to add source to pyproject.toml
382
+ const addSourceToPyproject = (content, source) => {
383
+ // Check if source already exists (by checking for the package name)
384
+ if (content.includes(`${flags.name} =`)) {
385
+ return content;
386
+ }
387
+ // Add to [tool.uv.sources] section
388
+ return content.replace('[tool.uv.sources]', `[tool.uv.sources]\n${source}\n`);
389
+ };
390
+ // Helper function to remove dependency from pyproject.toml
391
+ const removeDependencyFromPyproject = (content, depName) => {
392
+ // Remove from dependencies array - handle both formats: "depName", or "depName",\n
393
+ let result = content.replace(`"${depName}",`, '').replace(`"${depName}"`, '');
394
+ // Also try to remove with newline variations
395
+ result = result.replace(`\n"${depName}",`, '').replace(`"${depName}",\n`, '');
396
+ return result;
397
+ };
398
+ // Helper function to remove source from pyproject.toml
399
+ const removeSourceFromPyproject = (content, pkgName) => {
400
+ // Remove source line for this package
401
+ const sourceRegex = new RegExp(`${pkgName}\\s*=\\s*\\{[^}]*\\}\n?`, 'g');
402
+ return content.replace(sourceRegex, '');
403
+ };
404
+ let newPyproject = pyproject;
160
405
  if (!flags['dry-run']) {
161
- pyproject = pyproject.replace('dependencies = [', `dependencies = [\n"${flags.name}",`);
162
- pyproject = pyproject.replace('[tool.uv.sources]', `[tool.uv.sources]\n${lineToAdd}\n`);
406
+ if (args.op === 'install') {
407
+ newPyproject = addDependencyToPyproject(pyproject, flags.name);
408
+ newPyproject = addSourceToPyproject(newPyproject, lineToAdd);
409
+ }
410
+ else if (args.op === 'uninstall') {
411
+ newPyproject = removeDependencyFromPyproject(pyproject, flags.name);
412
+ newPyproject = removeSourceFromPyproject(newPyproject, flags.name);
413
+ }
163
414
  }
164
- const newPyproject = pyproject;
165
415
  // Add the elixir import and bootstrap to the server.py file
166
416
  let CliImport = `from ${expoName}.bootstrap import bootstrap as ${expoName}_bootstrap\n`;
167
417
  CliImport += `${expoName}_bootstrap()\n`;
@@ -171,48 +421,99 @@ ${expoName}_chain = ${expoName}_chain_class().get_chain_as_langchain_tool()
171
421
  ${expoName}_mcp_tool = ${expoName}_chain_class().get_chain_as_mcp_tool
172
422
  mcp_server.add_tool(${expoName}_mcp_tool) # type: ignore
173
423
  `;
174
- let newCliImport = '';
175
- if (!flags['dry-run']) {
176
- newCliImport = fs
177
- .readFileSync(`${flags.workdir}/elixir/app/server.py`, 'utf8')
178
- .replace('# DHTI_CLI_IMPORT', `#DHTI_CLI_IMPORT\n${CliImport}`);
179
- }
180
424
  const langfuseRoute = `add_routes(app, ${expoName}_chain.with_config(config), path="/langserve/${expoName}")`;
181
- const newLangfuseRoute = flags['dry-run'] ? '' : newCliImport.replace('# DHTI_LANGFUSE_ROUTE', `#DHTI_LANGFUSE_ROUTE\n ${langfuseRoute}`);
182
425
  const normalRoute = `add_routes(app, ${expoName}_chain, path="/langserve/${expoName}")`;
183
- const newNormalRoute = flags['dry-run'] ? '' : newLangfuseRoute.replace('# DHTI_NORMAL_ROUTE', `#DHTI_NORMAL_ROUTE\n ${normalRoute}`);
184
426
  const commonRoutes = `\nadd_invokes(app, path="/langserve/${expoName}")\nadd_services(app, path="/langserve/${expoName}")`;
185
- const finalRoute = flags['dry-run'] ? '' : newNormalRoute.replace('# DHTI_COMMON_ROUTE', `#DHTI_COMMON_ROUTES${commonRoutes}`);
186
- // if args.op === install, add the line to the pyproject.toml file
427
+ // Helper function to add elixir to server.py
428
+ // The strategy is to append imports BEFORE the 'import uvicorn' line
429
+ // and append routes AFTER the route marker comments
430
+ const addElixirToServer = (elixirName) => {
431
+ // Read fresh file content each time to get the current state
432
+ const content = flags['dry-run'] ? '' : fs.readFileSync(`${elixirDir}/app/server.py`, 'utf8');
433
+ if (flags['dry-run'] || content === '') {
434
+ return '';
435
+ }
436
+ // Check if elixir already installed
437
+ if (content.includes(`${elixirName}_bootstrap`)) {
438
+ return content;
439
+ }
440
+ let result = content;
441
+ // Find where to insert the import - look for 'import uvicorn' and insert before it
442
+ // This way, all imports are together before uvicorn import
443
+ if (!result.includes('import uvicorn')) {
444
+ console.error(chalk.red('Error: Could not find "import uvicorn" marker in server.py'));
445
+ return content;
446
+ }
447
+ result = result.replace('import uvicorn', `${CliImport}\nimport uvicorn`);
448
+ // For routes, we need to insert into both langfuse and normal route sections
449
+ // But only if they exist
450
+ // Find the langfuse try block and insert before 'except'
451
+ if (result.includes('except:')) {
452
+ // Insert langfuse route before except with proper indentation
453
+ result = result.replace('except:', ` ${langfuseRoute}\n\nexcept:`);
454
+ // Insert normal route in the except block, after the '# DHTI_NORMAL_ROUTE' marker
455
+ result = result.replace('# DHTI_NORMAL_ROUTE\n', `# DHTI_NORMAL_ROUTE\n ${normalRoute}\n`);
456
+ }
457
+ // For common routes, look for the marker and append with proper indentation
458
+ const commonRoutesMarker = '# DHTI_COMMON_ROUTE';
459
+ if (result.includes(commonRoutesMarker)) {
460
+ // Get the exact indentation by checking what comes after the marker
461
+ const markerIndex = result.indexOf(commonRoutesMarker);
462
+ const afterMarker = result.substring(markerIndex + commonRoutesMarker.length);
463
+ const newlineAndIndent = afterMarker.match(/\n[ \t]*/)?.[0] || '\n';
464
+ result = result.replace(commonRoutesMarker, `${commonRoutesMarker}${newlineAndIndent}${commonRoutes}`);
465
+ }
466
+ return result;
467
+ };
468
+ // Helper function to remove elixir from server.py
469
+ const removeElixirFromServer = () => {
470
+ // Read fresh file content each time to get the current state
471
+ const content = flags['dry-run'] ? '' : fs.readFileSync(`${elixirDir}/app/server.py`, 'utf8');
472
+ if (flags['dry-run'] || content === '') {
473
+ return '';
474
+ }
475
+ let result = content;
476
+ result = result.replace(CliImport, '');
477
+ result = result.replace(langfuseRoute, '');
478
+ result = result.replace(normalRoute, '');
479
+ result = result.replace(commonRoutes, '');
480
+ return result;
481
+ };
482
+ let finalRoute = '';
483
+ if (!flags['dry-run'] && args.op === 'install') {
484
+ finalRoute = addElixirToServer(expoName);
485
+ }
486
+ else if (!flags['dry-run'] && args.op === 'uninstall') {
487
+ finalRoute = removeElixirFromServer();
488
+ }
187
489
  if (args.op === 'install') {
188
490
  if (flags['dry-run']) {
189
491
  console.log(chalk.yellow('[DRY RUN] Would update files:'));
190
- console.log(chalk.cyan(` - ${flags.workdir}/elixir/pyproject.toml`));
492
+ console.log(chalk.cyan(` - ${elixirDir}/pyproject.toml`));
191
493
  console.log(chalk.green(` Add dependency: "${flags.name}"`));
192
494
  console.log(chalk.green(` Add source: ${lineToAdd}`));
193
- console.log(chalk.cyan(` - ${flags.workdir}/elixir/app/server.py`));
495
+ console.log(chalk.cyan(` - ${elixirDir}/app/server.py`));
194
496
  console.log(chalk.green(` Add import and routes for ${expoName}`));
195
497
  }
196
498
  else {
197
- fs.writeFileSync(`${flags.workdir}/elixir/pyproject.toml`, newPyproject);
198
- fs.writeFileSync(`${flags.workdir}/elixir/app/server.py`, finalRoute);
499
+ fs.writeFileSync(`${elixirDir}/pyproject.toml`, newPyproject);
500
+ fs.writeFileSync(`${elixirDir}/app/server.py`, finalRoute);
501
+ console.log(chalk.green(`✓ Elixir '${flags.name}' installed successfully`));
199
502
  }
200
503
  }
201
504
  if (args.op === 'uninstall') {
202
505
  if (flags['dry-run']) {
203
506
  console.log(chalk.yellow('[DRY RUN] Would update files:'));
204
- console.log(chalk.cyan(` - ${flags.workdir}/elixir/pyproject.toml`));
507
+ console.log(chalk.cyan(` - ${elixirDir}/pyproject.toml`));
508
+ console.log(chalk.green(` Remove dependency: "${flags.name}"`));
205
509
  console.log(chalk.green(` Remove source: ${lineToAdd}`));
206
- console.log(chalk.cyan(` - ${flags.workdir}/elixir/app/server.py`));
510
+ console.log(chalk.cyan(` - ${elixirDir}/app/server.py`));
207
511
  console.log(chalk.green(` Remove import and routes for ${expoName}`));
208
512
  }
209
513
  else {
210
- // if args.op === uninstall, remove the line from the pyproject.toml file
211
- fs.writeFileSync(`${flags.workdir}/elixir/pyproject.toml`, pyproject.replace(lineToAdd, ''));
212
- let newServer = originalServer.replace(CliImport, '');
213
- newServer = newServer.replace(langfuseRoute, '');
214
- newServer = newServer.replace(normalRoute, '');
215
- fs.writeFileSync(`${flags.workdir}/elixir/app/server.py`, newServer);
514
+ fs.writeFileSync(`${elixirDir}/pyproject.toml`, newPyproject);
515
+ fs.writeFileSync(`${elixirDir}/app/server.py`, finalRoute);
516
+ console.log(chalk.green(`✓ Elixir '${flags.name}' uninstalled successfully`));
216
517
  }
217
518
  }
218
519
  }
@@ -1,4 +1,3 @@
1
- version: "3.7"
2
1
 
3
2
  services:
4
3
  gateway:
@@ -72,6 +71,7 @@ services:
72
71
 
73
72
  langserve:
74
73
  image: beapen/genai:latest
74
+ network_mode: host
75
75
  pull_policy: always
76
76
  ports:
77
77
  - "8001:8001"
@@ -86,6 +86,8 @@ services:
86
86
  - OPENAI_API_KEY=${OPENAI_API_KEY:-openai-api-key}
87
87
  - OPENAI_API_BASE=${OPENAI_API_BASE:-https://openrouter.ai/api/v1}
88
88
  - OPENROUTER_API_KEY=${OPENROUTER_API_KEY:-openrouter-api-key}
89
+ - FHIR_BASE_URL=${FHIR_BASE_URL:-http://localhost:8080/openmrs/ws/fhir2/R4}
90
+
89
91
 
90
92
  ollama:
91
93
  image: ollama/ollama:latest
@@ -213,7 +215,7 @@ services:
213
215
  environment:
214
216
  - REDIS_HOSTS=local:redis:6379
215
217
  ports:
216
- - "8081:8081"
218
+ - "8181:8081"
217
219
 
218
220
  neo4j:
219
221
  image: neo4j:5.1-enterprise
@@ -0,0 +1,3 @@
1
+ ## Notes
2
+
3
+ * spa folder is not used and can be deleted once the old OpenMRS container based invocation is completely phased out.
@@ -0,0 +1,53 @@
1
+ import {Type, validator} from '@openmrs/esm-framework'
2
+
3
+ /**
4
+ * This is the config schema. It expects a configuration object which
5
+ * looks like this:
6
+ *
7
+ * ```json
8
+ * { "casualGreeting": true, "whoToGreet": ["Mom"] }
9
+ * ```
10
+ *
11
+ * In OpenMRS Microfrontends, all config parameters are optional. Thus,
12
+ * all elements must have a reasonable default. A good default is one
13
+ * that works well with the reference application.
14
+ *
15
+ * To understand the schema below, please read the configuration system
16
+ * documentation:
17
+ * https://openmrs.github.io/openmrs-esm-core/#/main/config
18
+ * Note especially the section "How do I make my module configurable?"
19
+ * https://openmrs.github.io/openmrs-esm-core/#/main/config?id=im-developing-an-esm-module-how-do-i-make-it-configurable
20
+ * and the Schema Reference
21
+ * https://openmrs.github.io/openmrs-esm-core/#/main/config?id=schema-reference
22
+ */
23
+ export const configSchema = {
24
+ casualGreeting: {
25
+ _type: Type.Boolean,
26
+ _default: false,
27
+ _description: 'Whether to use a casual greeting (or a formal one).',
28
+ },
29
+ dhtiTitle: {
30
+ _type: Type.String,
31
+ _default: 'DHTI Template Widget',
32
+ _description: 'Title to display for the DHTI Template Widget',
33
+ },
34
+ dhtiRoute: {
35
+ _type: Type.String,
36
+ _default: 'http://localhost:8001/langserve/dhti_elixir_template/cds-services/dhti-service',
37
+ _description: 'Route for the DHTI Template Service',
38
+ },
39
+ whoToGreet: {
40
+ _type: Type.Array,
41
+ _default: ['World'],
42
+ _description: 'Who should be greeted. Names will be separated by a comma and space.',
43
+ _elements: {
44
+ _type: Type.String,
45
+ },
46
+ _validators: [validator((v) => v.length > 0, 'At least one person must be greeted.')],
47
+ },
48
+ }
49
+
50
+ export type Config = {
51
+ casualGreeting: boolean
52
+ whoToGreet: Array<string>
53
+ }
@@ -0,0 +1,86 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import { useConfig, openmrsFetch, fhirBaseUrl } from '@openmrs/esm-framework';
3
+ import useSWR from 'swr';
4
+ import styles from './glycemic.scss';
5
+ import { useDhti } from './hooks/useDhti';
6
+
7
+ interface GlycemicWidgetProps {
8
+ patientUuid: string;
9
+ }
10
+
11
+ const GlycemicWidget: React.FC<GlycemicWidgetProps> = ({ patientUuid }) => {
12
+ const config = useConfig();
13
+ const serviceName = config?.dhtiServiceName || 'dhti_elixir_glycemic';
14
+ const { submitMessage, loading: aiLoading, error: aiError } = useDhti();
15
+ const [aiResponse, setAiResponse] = useState<any>(null);
16
+
17
+ // Check for Diabetes (SNOMED 44054006)
18
+ const { data: conditionsData, isLoading: conditionsLoading } = useSWR<any>(
19
+ patientUuid ? `${fhirBaseUrl}/Condition?patient=${patientUuid}&code=44054006` : null,
20
+ openmrsFetch
21
+ );
22
+
23
+ const isDiabetic = conditionsData?.data?.entry?.length > 0;
24
+
25
+ // Fetch HbA1c (4548-4) and Glucose (2339-0) observations
26
+ const { data: obsData, isLoading: obsLoading } = useSWR<any>(
27
+ isDiabetic ? `${fhirBaseUrl}/Observation?patient=${patientUuid}&code=4548-4,2339-0&_sort=-date&_count=5` : null,
28
+ openmrsFetch
29
+ );
30
+
31
+ useEffect(() => {
32
+ if (!isDiabetic && !aiResponse && !aiLoading && !aiError) {
33
+ submitMessage('Analyze glycemic control', patientUuid).then(setAiResponse);
34
+ }
35
+ }, [isDiabetic, patientUuid, submitMessage, aiResponse, aiLoading, aiError]);
36
+
37
+ // if (conditionsLoading) return null;
38
+ // if (!isDiabetic) return null;
39
+
40
+ return (
41
+ <div className={styles.container}>
42
+ <div className={styles.header}>
43
+ <h3>{config?.dhtiTitle || 'DHTI Glycemic Widget'}</h3>
44
+ </div>
45
+
46
+ <div className={styles.content}>
47
+ <div className={styles.observations}>
48
+ <h4>Latest Readings</h4>
49
+ {obsLoading ? (
50
+ <p>Loading observations...</p>
51
+ ) : obsData?.data?.entry?.length > 0 ? (
52
+ <ul>
53
+ {obsData.data.entry.map((entry: any) => {
54
+ const obs = entry.resource;
55
+ const value = obs.valueQuantity ? `${obs.valueQuantity.value} ${obs.valueQuantity.unit}` : obs.valueString;
56
+ const date = new Date(obs.effectiveDateTime).toLocaleDateString();
57
+ const name = obs.code?.text || obs.code?.coding?.[0]?.display || 'Observation';
58
+ return (
59
+ <li key={obs.id}>
60
+ <strong>{name}:</strong> {value} <small>({date})</small>
61
+ </li>
62
+ );
63
+ })}
64
+ </ul>
65
+ ) : (
66
+ <p>No recent HbA1c or Glucose readings found.</p>
67
+ )}
68
+ </div>
69
+
70
+ <div className={styles.aiInsights}>
71
+ <h4>GenAI Interpretation</h4>
72
+ {aiLoading && <p>Generating insights...</p>}
73
+ {aiError && <p className={styles.error}>Error: {aiError}</p>}
74
+ {aiResponse && (
75
+ <div className={styles.aiContent}>
76
+ <p>{aiResponse.summary}</p>
77
+ {aiResponse.detail && <p><small>{aiResponse.detail}</small></p>}
78
+ </div>
79
+ )}
80
+ </div>
81
+ </div>
82
+ </div>
83
+ );
84
+ };
85
+
86
+ export default GlycemicWidget;