dhti-cli 0.5.0 → 0.7.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.
- package/README.md +12 -4
- package/dist/commands/compose.js +3 -1
- package/dist/commands/docktor.d.ts +1 -1
- package/dist/commands/docktor.js +88 -78
- package/dist/commands/mimic.d.ts +1 -0
- package/dist/commands/mimic.js +24 -6
- package/dist/commands/synthea.d.ts +73 -0
- package/dist/commands/synthea.js +579 -0
- package/dist/resources/docker-compose-master.yml +135 -23
- package/oclif.manifest.json +176 -1
- package/package.json +3 -2
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
import { Args, Command, Flags } from '@oclif/core';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { exec } from 'node:child_process';
|
|
4
|
+
import { createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { createInterface } from 'node:readline';
|
|
8
|
+
import { promisify } from 'node:util';
|
|
9
|
+
const execAsync = promisify(exec);
|
|
10
|
+
/**
|
|
11
|
+
* Synthea command for managing synthetic FHIR data generation
|
|
12
|
+
*
|
|
13
|
+
* This command provides subcommands to:
|
|
14
|
+
* - install: Download and install Synthea JAR file
|
|
15
|
+
* - generate: Generate synthetic FHIR data using Synthea
|
|
16
|
+
* - upload: Upload generated FHIR resources to a FHIR server
|
|
17
|
+
* - delete: Clean up generated synthetic data
|
|
18
|
+
* - download: Download pre-generated Synthea datasets
|
|
19
|
+
*/
|
|
20
|
+
export default class Synthea extends Command {
|
|
21
|
+
static args = {
|
|
22
|
+
subcommand: Args.string({
|
|
23
|
+
description: 'Subcommand to execute: install, generate, upload, delete, download',
|
|
24
|
+
options: ['install', 'generate', 'upload', 'delete', 'download'],
|
|
25
|
+
required: true,
|
|
26
|
+
}),
|
|
27
|
+
};
|
|
28
|
+
static description = 'Manage Synthea synthetic FHIR data generation';
|
|
29
|
+
static examples = [
|
|
30
|
+
'<%= config.bin %> <%= command.id %> install',
|
|
31
|
+
'<%= config.bin %> <%= command.id %> generate -p 10',
|
|
32
|
+
'<%= config.bin %> <%= command.id %> upload -e http://fhir:8005/baseR4',
|
|
33
|
+
'<%= config.bin %> <%= command.id %> delete',
|
|
34
|
+
'<%= config.bin %> <%= command.id %> download --covid19',
|
|
35
|
+
];
|
|
36
|
+
static flags = {
|
|
37
|
+
// Generate flags
|
|
38
|
+
age: Flags.string({
|
|
39
|
+
char: 'a',
|
|
40
|
+
description: 'Generate patients with specific age range (e.g., "0-18" for pediatric)',
|
|
41
|
+
}),
|
|
42
|
+
city: Flags.string({
|
|
43
|
+
char: 'c',
|
|
44
|
+
description: 'City for patient generation',
|
|
45
|
+
}),
|
|
46
|
+
// Download flags - various datasets from synthea.mitre.org
|
|
47
|
+
covid19: Flags.boolean({
|
|
48
|
+
description: 'Download COVID-19 dataset (1k patients)',
|
|
49
|
+
}),
|
|
50
|
+
// eslint-disable-next-line camelcase
|
|
51
|
+
covid19_10k: Flags.boolean({
|
|
52
|
+
description: 'Download COVID-19 dataset (10k patients)',
|
|
53
|
+
}),
|
|
54
|
+
// eslint-disable-next-line camelcase
|
|
55
|
+
covid19_csv: Flags.boolean({
|
|
56
|
+
description: 'Download COVID-19 CSV dataset (1k patients)',
|
|
57
|
+
}),
|
|
58
|
+
// eslint-disable-next-line camelcase
|
|
59
|
+
covid19_csv_10k: Flags.boolean({
|
|
60
|
+
description: 'Download COVID-19 CSV dataset (10k patients)',
|
|
61
|
+
}),
|
|
62
|
+
// Common flags
|
|
63
|
+
'dry-run': Flags.boolean({
|
|
64
|
+
default: false,
|
|
65
|
+
description: 'Show what changes would be made without actually making them',
|
|
66
|
+
}),
|
|
67
|
+
// Upload flags
|
|
68
|
+
endpoint: Flags.string({
|
|
69
|
+
char: 'e',
|
|
70
|
+
default: 'http://fhir:8005/baseR4',
|
|
71
|
+
description: 'FHIR server endpoint URL',
|
|
72
|
+
}),
|
|
73
|
+
gender: Flags.string({
|
|
74
|
+
char: 'g',
|
|
75
|
+
description: 'Generate patients of specific gender (M or F)',
|
|
76
|
+
options: ['M', 'F'],
|
|
77
|
+
}),
|
|
78
|
+
population: Flags.integer({
|
|
79
|
+
char: 'p',
|
|
80
|
+
default: 1,
|
|
81
|
+
description: 'Number of patients to generate',
|
|
82
|
+
}),
|
|
83
|
+
seed: Flags.string({
|
|
84
|
+
char: 's',
|
|
85
|
+
description: 'Random seed for reproducible generation',
|
|
86
|
+
}),
|
|
87
|
+
state: Flags.string({
|
|
88
|
+
description: 'State for patient generation (default: Massachusetts)',
|
|
89
|
+
}),
|
|
90
|
+
// eslint-disable-next-line camelcase
|
|
91
|
+
synthea_sample_data_csv_latest: Flags.boolean({
|
|
92
|
+
description: 'Download latest CSV sample data',
|
|
93
|
+
}),
|
|
94
|
+
// eslint-disable-next-line camelcase
|
|
95
|
+
synthea_sample_data_fhir_latest: Flags.boolean({
|
|
96
|
+
description: 'Download latest FHIR sample data',
|
|
97
|
+
}),
|
|
98
|
+
// eslint-disable-next-line camelcase
|
|
99
|
+
synthea_sample_data_fhir_stu3_latest: Flags.boolean({
|
|
100
|
+
description: 'Download latest FHIR STU3 sample data',
|
|
101
|
+
}),
|
|
102
|
+
token: Flags.string({
|
|
103
|
+
char: 't',
|
|
104
|
+
description: 'Bearer token for FHIR server authentication',
|
|
105
|
+
}),
|
|
106
|
+
workdir: Flags.string({
|
|
107
|
+
char: 'w',
|
|
108
|
+
default: join(homedir(), 'dhti'),
|
|
109
|
+
description: 'Working directory for Synthea files',
|
|
110
|
+
}),
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* Main command execution
|
|
114
|
+
* Dispatches to appropriate subcommand handler
|
|
115
|
+
* @returns Promise that resolves when subcommand completes
|
|
116
|
+
*/
|
|
117
|
+
async run() {
|
|
118
|
+
const { args, flags } = await this.parse(Synthea);
|
|
119
|
+
// Execute appropriate subcommand
|
|
120
|
+
switch (args.subcommand) {
|
|
121
|
+
case 'install': {
|
|
122
|
+
await this.install(flags);
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
case 'generate': {
|
|
126
|
+
await this.generate(flags);
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
case 'upload': {
|
|
130
|
+
await this.upload(flags);
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
case 'delete': {
|
|
134
|
+
await this.delete(flags);
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
case 'download': {
|
|
138
|
+
await this.download(flags);
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
default: {
|
|
142
|
+
this.error(`Unknown subcommand: ${args.subcommand}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Delete synthetic data
|
|
148
|
+
* @param flags Command flags including workdir and dry-run
|
|
149
|
+
* @returns Promise that resolves when deletion completes
|
|
150
|
+
*/
|
|
151
|
+
async delete(flags) {
|
|
152
|
+
const dataDir = join(flags.workdir, 'synthea_data');
|
|
153
|
+
if (flags['dry-run']) {
|
|
154
|
+
console.log(chalk.yellow('[DRY RUN] Data deletion simulation'));
|
|
155
|
+
console.log(chalk.cyan(` Data directory: ${dataDir}`));
|
|
156
|
+
console.log(chalk.green('[DRY RUN] Would delete all files in synthea_data directory'));
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
// Check if directory exists
|
|
160
|
+
if (!existsSync(dataDir)) {
|
|
161
|
+
console.log(chalk.yellow(`⚠ Directory does not exist: ${dataDir}`));
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
// Count files
|
|
165
|
+
let fileCount = 0;
|
|
166
|
+
const countFiles = (dir) => {
|
|
167
|
+
const items = readdirSync(dir);
|
|
168
|
+
for (const item of items) {
|
|
169
|
+
const fullPath = join(dir, item);
|
|
170
|
+
const stat = statSync(fullPath);
|
|
171
|
+
if (stat.isDirectory()) {
|
|
172
|
+
countFiles(fullPath);
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
fileCount++;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
countFiles(dataDir);
|
|
180
|
+
console.log(chalk.yellow(`⚠ About to delete ${fileCount} files from: ${dataDir}`));
|
|
181
|
+
// Confirmation prompt
|
|
182
|
+
const rl = createInterface({
|
|
183
|
+
input: process.stdin,
|
|
184
|
+
output: process.stdout,
|
|
185
|
+
});
|
|
186
|
+
const answer = await new Promise((resolve) => {
|
|
187
|
+
rl.question(chalk.red('Are you sure you want to delete all data? (yes/N): '), resolve);
|
|
188
|
+
});
|
|
189
|
+
rl.close();
|
|
190
|
+
if (answer.toLowerCase() !== 'yes') {
|
|
191
|
+
console.log(chalk.blue('Deletion cancelled.'));
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
// Delete directory
|
|
195
|
+
try {
|
|
196
|
+
rmSync(dataDir, { force: true, recursive: true });
|
|
197
|
+
console.log(chalk.green(`✓ Deleted: ${dataDir}`));
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
this.error(`Failed to delete directory: ${error instanceof Error ? error.message : String(error)}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Download pre-generated Synthea datasets
|
|
205
|
+
* @param flags Command flags including workdir, dataset selections, and dry-run
|
|
206
|
+
* @returns Promise that resolves when download completes
|
|
207
|
+
*/
|
|
208
|
+
async download(flags) {
|
|
209
|
+
const tmpDir = '/tmp/synthea_downloads';
|
|
210
|
+
const outputDir = join(flags.workdir, 'synthea_data');
|
|
211
|
+
// Map of dataset flags to download URLs
|
|
212
|
+
// eslint-disable-next-line camelcase
|
|
213
|
+
const datasets = {
|
|
214
|
+
covid19: {
|
|
215
|
+
file: 'covid19.zip',
|
|
216
|
+
url: 'https://synthea.mitre.org/downloads/covid19_1k.zip',
|
|
217
|
+
},
|
|
218
|
+
// eslint-disable-next-line camelcase
|
|
219
|
+
covid19_10k: {
|
|
220
|
+
file: 'covid19_10k.zip',
|
|
221
|
+
url: 'https://synthea.mitre.org/downloads/covid19_10k.zip',
|
|
222
|
+
},
|
|
223
|
+
// eslint-disable-next-line camelcase
|
|
224
|
+
covid19_csv: {
|
|
225
|
+
file: 'covid19_csv.zip',
|
|
226
|
+
url: 'https://synthea.mitre.org/downloads/covid19_csv_1k.zip',
|
|
227
|
+
},
|
|
228
|
+
// eslint-disable-next-line camelcase
|
|
229
|
+
covid19_csv_10k: {
|
|
230
|
+
file: 'covid19_csv_10k.zip',
|
|
231
|
+
url: 'https://synthea.mitre.org/downloads/covid19_csv_10k.zip',
|
|
232
|
+
},
|
|
233
|
+
// eslint-disable-next-line camelcase
|
|
234
|
+
synthea_sample_data_csv_latest: {
|
|
235
|
+
file: 'synthea_sample_data_csv_latest.zip',
|
|
236
|
+
url: 'https://synthea.mitre.org/downloads/synthea_sample_data_csv_latest.zip',
|
|
237
|
+
},
|
|
238
|
+
// eslint-disable-next-line camelcase
|
|
239
|
+
synthea_sample_data_fhir_latest: {
|
|
240
|
+
file: 'synthea_sample_data_fhir_latest.zip',
|
|
241
|
+
url: 'https://synthea.mitre.org/downloads/synthea_sample_data_fhir_latest.zip',
|
|
242
|
+
},
|
|
243
|
+
// eslint-disable-next-line camelcase
|
|
244
|
+
synthea_sample_data_fhir_stu3_latest: {
|
|
245
|
+
file: 'synthea_sample_data_fhir_stu3_latest.zip',
|
|
246
|
+
url: 'https://synthea.mitre.org/downloads/synthea_sample_data_fhir_stu3_latest.zip',
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
// Find which dataset to download
|
|
250
|
+
const selectedDatasets = Object.keys(datasets).filter((key) => flags[key]);
|
|
251
|
+
if (selectedDatasets.length === 0) {
|
|
252
|
+
if (flags['dry-run']) {
|
|
253
|
+
console.log(chalk.yellow('[DRY RUN] Dataset download simulation'));
|
|
254
|
+
console.log(chalk.yellow('⚠ No dataset selected. Use one of the following flags:'));
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
console.log(chalk.yellow('⚠ No dataset selected. Use one of the following flags:'));
|
|
258
|
+
}
|
|
259
|
+
for (const [key] of Object.entries(datasets)) {
|
|
260
|
+
console.log(chalk.cyan(` --${key}`));
|
|
261
|
+
}
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (flags['dry-run']) {
|
|
265
|
+
console.log(chalk.yellow('[DRY RUN] Dataset download simulation'));
|
|
266
|
+
console.log(chalk.cyan(` Temporary directory: ${tmpDir}`));
|
|
267
|
+
console.log(chalk.cyan(` Output directory: ${outputDir}`));
|
|
268
|
+
for (const dataset of selectedDatasets) {
|
|
269
|
+
console.log(chalk.cyan(` Dataset: ${dataset}`));
|
|
270
|
+
console.log(chalk.cyan(` URL: ${datasets[dataset].url}`));
|
|
271
|
+
}
|
|
272
|
+
console.log(chalk.green('[DRY RUN] Would download and extract selected datasets'));
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
// Create directories
|
|
276
|
+
if (!existsSync(tmpDir)) {
|
|
277
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
278
|
+
}
|
|
279
|
+
if (!existsSync(outputDir)) {
|
|
280
|
+
mkdirSync(outputDir, { recursive: true });
|
|
281
|
+
}
|
|
282
|
+
// Download and extract each selected dataset
|
|
283
|
+
// Note: Sequential processing is intentional to avoid overwhelming the server
|
|
284
|
+
// eslint-disable-next-line no-await-in-loop
|
|
285
|
+
for (const datasetKey of selectedDatasets) {
|
|
286
|
+
const dataset = datasets[datasetKey];
|
|
287
|
+
const downloadPath = join(tmpDir, dataset.file);
|
|
288
|
+
console.log(chalk.blue(`\nDownloading ${datasetKey}...`));
|
|
289
|
+
console.log(chalk.gray(`URL: ${dataset.url}`));
|
|
290
|
+
try {
|
|
291
|
+
// Download file
|
|
292
|
+
// eslint-disable-next-line no-await-in-loop
|
|
293
|
+
const response = await fetch(dataset.url);
|
|
294
|
+
if (!response.ok) {
|
|
295
|
+
throw new Error(`Failed to download: ${response.statusText}`);
|
|
296
|
+
}
|
|
297
|
+
const fileStream = createWriteStream(downloadPath);
|
|
298
|
+
// @ts-expect-error - ReadableStream types from fetch
|
|
299
|
+
const reader = response.body.getReader();
|
|
300
|
+
let downloadedBytes = 0;
|
|
301
|
+
const contentLength = Number.parseInt(response.headers.get('content-length') || '0', 10);
|
|
302
|
+
// eslint-disable-next-line no-constant-condition
|
|
303
|
+
while (true) {
|
|
304
|
+
// eslint-disable-next-line no-await-in-loop
|
|
305
|
+
const { done, value } = await reader.read();
|
|
306
|
+
if (done)
|
|
307
|
+
break;
|
|
308
|
+
downloadedBytes += value.length;
|
|
309
|
+
fileStream.write(value);
|
|
310
|
+
if (contentLength > 0) {
|
|
311
|
+
const progress = Math.round((downloadedBytes / contentLength) * 100);
|
|
312
|
+
process.stdout.write(`\rDownloading: ${progress}%`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
fileStream.end();
|
|
316
|
+
console.log('\n' + chalk.green(`✓ Downloaded ${dataset.file}`));
|
|
317
|
+
// Extract ZIP file
|
|
318
|
+
console.log(chalk.blue('Extracting...'));
|
|
319
|
+
// eslint-disable-next-line no-await-in-loop
|
|
320
|
+
await execAsync(`unzip -o "${downloadPath}" -d "${outputDir}"`);
|
|
321
|
+
console.log(chalk.green(`✓ Extracted to ${outputDir}`));
|
|
322
|
+
}
|
|
323
|
+
catch (error) {
|
|
324
|
+
console.log(chalk.red(`✗ Failed to download ${datasetKey}: ${error instanceof Error ? error.message : String(error)}`));
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
console.log(chalk.green(`\n✓ Download complete. Data available at: ${outputDir}`));
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Generate synthetic FHIR data
|
|
331
|
+
* @param flags Command flags including population, state, city, gender, age, seed, workdir, and dry-run
|
|
332
|
+
* @returns Promise that resolves when generation completes
|
|
333
|
+
*/
|
|
334
|
+
async generate(flags) {
|
|
335
|
+
const syntheaDir = join(flags.workdir, 'synthea');
|
|
336
|
+
const jarPath = join(syntheaDir, 'synthea-with-dependencies.jar');
|
|
337
|
+
const outputDir = join(flags.workdir, 'synthea_data');
|
|
338
|
+
if (flags['dry-run']) {
|
|
339
|
+
console.log(chalk.yellow('[DRY RUN] Synthetic data generation simulation'));
|
|
340
|
+
console.log(chalk.cyan(` Synthea JAR: ${jarPath}`));
|
|
341
|
+
console.log(chalk.cyan(` Output directory: ${outputDir}`));
|
|
342
|
+
console.log(chalk.cyan(` Population: ${flags.population} patients`));
|
|
343
|
+
if (flags.state)
|
|
344
|
+
console.log(chalk.cyan(` State: ${flags.state}`));
|
|
345
|
+
if (flags.city)
|
|
346
|
+
console.log(chalk.cyan(` City: ${flags.city}`));
|
|
347
|
+
if (flags.gender)
|
|
348
|
+
console.log(chalk.cyan(` Gender: ${flags.gender}`));
|
|
349
|
+
if (flags.age)
|
|
350
|
+
console.log(chalk.cyan(` Age range: ${flags.age}`));
|
|
351
|
+
if (flags.seed)
|
|
352
|
+
console.log(chalk.cyan(` Random seed: ${flags.seed}`));
|
|
353
|
+
console.log(chalk.green('[DRY RUN] Would create output directory'));
|
|
354
|
+
console.log(chalk.green('[DRY RUN] Would execute Synthea JAR to generate data'));
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
// Check if JAR exists
|
|
358
|
+
if (!existsSync(jarPath)) {
|
|
359
|
+
console.log(chalk.red(`✗ Synthea JAR not found at: ${jarPath}\nRun 'dhti-cli synthea install' first.`));
|
|
360
|
+
this.exit(1);
|
|
361
|
+
}
|
|
362
|
+
// Create output directory
|
|
363
|
+
if (!existsSync(outputDir)) {
|
|
364
|
+
mkdirSync(outputDir, { recursive: true });
|
|
365
|
+
console.log(chalk.green(`✓ Created output directory: ${outputDir}`));
|
|
366
|
+
}
|
|
367
|
+
// Build Synthea command
|
|
368
|
+
const javaArgs = ['-jar', jarPath];
|
|
369
|
+
// Add optional flags
|
|
370
|
+
if (flags.population)
|
|
371
|
+
javaArgs.push('-p', String(flags.population));
|
|
372
|
+
if (flags.state)
|
|
373
|
+
javaArgs.push('-s', flags.state);
|
|
374
|
+
if (flags.city)
|
|
375
|
+
javaArgs.push('-c', flags.city);
|
|
376
|
+
if (flags.gender)
|
|
377
|
+
javaArgs.push('-g', flags.gender);
|
|
378
|
+
if (flags.age)
|
|
379
|
+
javaArgs.push('-a', flags.age);
|
|
380
|
+
if (flags.seed)
|
|
381
|
+
javaArgs.push('--seed', flags.seed);
|
|
382
|
+
// Set output directory
|
|
383
|
+
javaArgs.push('--exporter.baseDirectory', outputDir);
|
|
384
|
+
console.log(chalk.blue('Generating synthetic data...'));
|
|
385
|
+
console.log(chalk.gray(`Command: java ${javaArgs.join(' ')}`));
|
|
386
|
+
try {
|
|
387
|
+
const { stderr, stdout } = await execAsync(`java ${javaArgs.join(' ')}`, {
|
|
388
|
+
cwd: syntheaDir,
|
|
389
|
+
maxBuffer: 10 * 1024 * 1024, // 10MB buffer
|
|
390
|
+
});
|
|
391
|
+
if (stdout)
|
|
392
|
+
console.log(stdout);
|
|
393
|
+
if (stderr)
|
|
394
|
+
console.error(chalk.yellow(stderr));
|
|
395
|
+
console.log(chalk.green(`✓ Generated synthetic data in: ${outputDir}`));
|
|
396
|
+
// Show FHIR output location
|
|
397
|
+
const fhirDir = join(outputDir, 'fhir');
|
|
398
|
+
if (existsSync(fhirDir)) {
|
|
399
|
+
const files = readdirSync(fhirDir);
|
|
400
|
+
console.log(chalk.cyan(`\nGenerated ${files.length} FHIR resource files`));
|
|
401
|
+
console.log(chalk.white(`FHIR files location: ${fhirDir}`));
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
catch (error) {
|
|
405
|
+
this.error(`Failed to generate synthetic data: ${error instanceof Error ? error.message : String(error)}`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Install Synthea JAR file
|
|
410
|
+
* @param flags Command flags including workdir and dry-run
|
|
411
|
+
* @returns Promise that resolves when installation completes
|
|
412
|
+
*/
|
|
413
|
+
async install(flags) {
|
|
414
|
+
const syntheaDir = join(flags.workdir, 'synthea');
|
|
415
|
+
const jarPath = join(syntheaDir, 'synthea-with-dependencies.jar');
|
|
416
|
+
if (flags['dry-run']) {
|
|
417
|
+
console.log(chalk.yellow('[DRY RUN] Synthea installation simulation'));
|
|
418
|
+
console.log(chalk.cyan(` Working directory: ${flags.workdir}`));
|
|
419
|
+
console.log(chalk.cyan(` Synthea directory: ${syntheaDir}`));
|
|
420
|
+
console.log(chalk.cyan(` JAR path: ${jarPath}`));
|
|
421
|
+
console.log(chalk.green('[DRY RUN] Would create synthea directory'));
|
|
422
|
+
console.log(chalk.green('[DRY RUN] Would download synthea-with-dependencies.jar'));
|
|
423
|
+
console.log(chalk.green('[DRY RUN] Would display usage instructions'));
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
// Create synthea directory
|
|
427
|
+
if (!existsSync(syntheaDir)) {
|
|
428
|
+
mkdirSync(syntheaDir, { recursive: true });
|
|
429
|
+
console.log(chalk.green(`✓ Created directory: ${syntheaDir}`));
|
|
430
|
+
}
|
|
431
|
+
// Check if JAR already exists
|
|
432
|
+
if (existsSync(jarPath)) {
|
|
433
|
+
console.log(chalk.yellow(`⚠ Synthea JAR already exists at: ${jarPath}`));
|
|
434
|
+
const rl = createInterface({
|
|
435
|
+
input: process.stdin,
|
|
436
|
+
output: process.stdout,
|
|
437
|
+
});
|
|
438
|
+
const answer = await new Promise((resolve) => {
|
|
439
|
+
rl.question('Overwrite existing file? (y/N): ', resolve);
|
|
440
|
+
});
|
|
441
|
+
rl.close();
|
|
442
|
+
if (answer.toLowerCase() !== 'y') {
|
|
443
|
+
console.log(chalk.blue('Installation cancelled.'));
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
// Download synthea-with-dependencies.jar
|
|
448
|
+
console.log(chalk.blue('Downloading synthea-with-dependencies.jar...'));
|
|
449
|
+
const downloadUrl = 'https://github.com/synthetichealth/synthea/releases/download/master-branch-latest/synthea-with-dependencies.jar';
|
|
450
|
+
try {
|
|
451
|
+
const response = await fetch(downloadUrl);
|
|
452
|
+
if (!response.ok) {
|
|
453
|
+
throw new Error(`Failed to download: ${response.statusText}`);
|
|
454
|
+
}
|
|
455
|
+
const fileStream = createWriteStream(jarPath);
|
|
456
|
+
// @ts-expect-error - ReadableStream types from fetch
|
|
457
|
+
const reader = response.body.getReader();
|
|
458
|
+
let downloadedBytes = 0;
|
|
459
|
+
const contentLength = Number.parseInt(response.headers.get('content-length') || '0', 10);
|
|
460
|
+
// eslint-disable-next-line no-constant-condition
|
|
461
|
+
while (true) {
|
|
462
|
+
// eslint-disable-next-line no-await-in-loop
|
|
463
|
+
const { done, value } = await reader.read();
|
|
464
|
+
if (done)
|
|
465
|
+
break;
|
|
466
|
+
downloadedBytes += value.length;
|
|
467
|
+
fileStream.write(value);
|
|
468
|
+
if (contentLength > 0) {
|
|
469
|
+
const progress = Math.round((downloadedBytes / contentLength) * 100);
|
|
470
|
+
process.stdout.write(`\rDownloading: ${progress}%`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
fileStream.end();
|
|
474
|
+
console.log('\n' + chalk.green(`✓ Downloaded synthea-with-dependencies.jar to ${jarPath}`));
|
|
475
|
+
}
|
|
476
|
+
catch (error) {
|
|
477
|
+
this.error(`Failed to download Synthea JAR: ${error instanceof Error ? error.message : String(error)}`);
|
|
478
|
+
}
|
|
479
|
+
// Display usage instructions
|
|
480
|
+
console.log(chalk.cyan('\n' + '='.repeat(60)));
|
|
481
|
+
console.log(chalk.bold.green('Synthea Installation Complete!'));
|
|
482
|
+
console.log(chalk.cyan('='.repeat(60)));
|
|
483
|
+
console.log(chalk.white('\nUsage Instructions:'));
|
|
484
|
+
console.log(chalk.white('-------------------'));
|
|
485
|
+
console.log(chalk.white('To generate synthetic data:'));
|
|
486
|
+
console.log(chalk.yellow(` ${this.config.bin} synthea generate -p 10`));
|
|
487
|
+
console.log(chalk.white('\nTo upload data to FHIR server:'));
|
|
488
|
+
console.log(chalk.yellow(` ${this.config.bin} synthea upload -e http://fhir:8005/baseR4`));
|
|
489
|
+
console.log(chalk.white('\nManual usage:'));
|
|
490
|
+
console.log(chalk.yellow(` cd ${syntheaDir}`));
|
|
491
|
+
console.log(chalk.yellow(' java -jar synthea-with-dependencies.jar -p 10'));
|
|
492
|
+
console.log(chalk.white('\nFor more options, see:'));
|
|
493
|
+
console.log(chalk.blue(' https://github.com/synthetichealth/synthea/wiki/Basic-Setup-and-Running'));
|
|
494
|
+
console.log(chalk.cyan('='.repeat(60) + '\n'));
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Upload FHIR resources to server
|
|
498
|
+
* @param flags Command flags including endpoint, token, workdir, and dry-run
|
|
499
|
+
* @returns Promise that resolves when upload completes
|
|
500
|
+
*/
|
|
501
|
+
async upload(flags) {
|
|
502
|
+
const fhirDir = join(flags.workdir, 'synthea_data', 'fhir');
|
|
503
|
+
if (flags['dry-run']) {
|
|
504
|
+
console.log(chalk.yellow('[DRY RUN] FHIR upload simulation'));
|
|
505
|
+
console.log(chalk.cyan(` FHIR directory: ${fhirDir}`));
|
|
506
|
+
console.log(chalk.cyan(` Endpoint: ${flags.endpoint}`));
|
|
507
|
+
if (flags.token)
|
|
508
|
+
console.log(chalk.cyan(' Authentication: Bearer token'));
|
|
509
|
+
console.log(chalk.green('[DRY RUN] Would read FHIR resources from directory'));
|
|
510
|
+
console.log(chalk.green('[DRY RUN] Would upload each resource to FHIR server'));
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
// Check if FHIR directory exists
|
|
514
|
+
if (!existsSync(fhirDir)) {
|
|
515
|
+
console.log(chalk.red(`✗ FHIR data directory not found: ${fhirDir}\nRun 'dhti-cli synthea generate' first.`));
|
|
516
|
+
this.exit(1);
|
|
517
|
+
}
|
|
518
|
+
// Read all JSON files from FHIR directory
|
|
519
|
+
const files = readdirSync(fhirDir).filter((f) => f.endsWith('.json'));
|
|
520
|
+
if (files.length === 0) {
|
|
521
|
+
console.log(chalk.yellow('⚠ No FHIR JSON files found in directory'));
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
console.log(chalk.blue(`Found ${files.length} FHIR resource files`));
|
|
525
|
+
// Prepare headers
|
|
526
|
+
const headers = {
|
|
527
|
+
'Content-Type': 'application/fhir+json',
|
|
528
|
+
};
|
|
529
|
+
if (flags.token) {
|
|
530
|
+
headers.Authorization = `Bearer ${flags.token}`;
|
|
531
|
+
}
|
|
532
|
+
let successCount = 0;
|
|
533
|
+
let failCount = 0;
|
|
534
|
+
// Upload each file
|
|
535
|
+
// Note: Sequential processing is intentional to maintain order and avoid overwhelming server
|
|
536
|
+
// eslint-disable-next-line no-await-in-loop
|
|
537
|
+
for (const [index, file] of files.entries()) {
|
|
538
|
+
const filePath = join(fhirDir, file);
|
|
539
|
+
console.log(chalk.gray(`[${index + 1}/${files.length}] Uploading ${file}...`));
|
|
540
|
+
try {
|
|
541
|
+
const content = readFileSync(filePath, 'utf8');
|
|
542
|
+
const resource = JSON.parse(content);
|
|
543
|
+
// Determine resource type and construct URL
|
|
544
|
+
const { resourceType } = resource;
|
|
545
|
+
if (!resourceType) {
|
|
546
|
+
console.log(chalk.yellow(` ⚠ Skipping ${file} - no resourceType`));
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
const url = `${flags.endpoint}/${resourceType}`;
|
|
550
|
+
// eslint-disable-next-line no-await-in-loop
|
|
551
|
+
const response = await fetch(url, {
|
|
552
|
+
body: content,
|
|
553
|
+
headers,
|
|
554
|
+
method: 'POST',
|
|
555
|
+
});
|
|
556
|
+
if (response.ok) {
|
|
557
|
+
successCount++;
|
|
558
|
+
console.log(chalk.green(` ✓ Uploaded ${file}`));
|
|
559
|
+
}
|
|
560
|
+
else {
|
|
561
|
+
failCount++;
|
|
562
|
+
console.log(chalk.red(` ✗ Failed to upload ${file}: ${response.status} ${response.statusText}`));
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
catch (error) {
|
|
566
|
+
failCount++;
|
|
567
|
+
console.log(chalk.red(` ✗ Error uploading ${file}: ${error instanceof Error ? error.message : String(error)}`));
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
// Summary
|
|
571
|
+
console.log(chalk.cyan('\n' + '='.repeat(60)));
|
|
572
|
+
console.log(chalk.bold.white('Upload Summary'));
|
|
573
|
+
console.log(chalk.cyan('='.repeat(60)));
|
|
574
|
+
console.log(chalk.green(` ✓ Successful: ${successCount}`));
|
|
575
|
+
console.log(chalk.red(` ✗ Failed: ${failCount}`));
|
|
576
|
+
console.log(chalk.white(` Total: ${files.length}`));
|
|
577
|
+
console.log(chalk.cyan('='.repeat(60) + '\n'));
|
|
578
|
+
}
|
|
579
|
+
}
|