@medplum/cli 2.0.16 → 2.0.18
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 +1 -252
- package/dist/cjs/index.cjs +593 -154
- package/dist/cjs/index.cjs.map +1 -1
- package/package.json +11 -3
package/dist/cjs/index.cjs
CHANGED
|
@@ -1,48 +1,229 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
-
var commander = require('commander');
|
|
5
4
|
var core = require('@medplum/core');
|
|
5
|
+
var commander = require('commander');
|
|
6
|
+
var dotenv = require('dotenv');
|
|
7
|
+
var clientCloudformation = require('@aws-sdk/client-cloudformation');
|
|
8
|
+
var clientCloudfront = require('@aws-sdk/client-cloudfront');
|
|
9
|
+
var clientEcs = require('@aws-sdk/client-ecs');
|
|
10
|
+
var clientS3 = require('@aws-sdk/client-s3');
|
|
11
|
+
var fastGlob = require('fast-glob');
|
|
6
12
|
var fs = require('fs');
|
|
13
|
+
var fetch$1 = require('node-fetch');
|
|
7
14
|
var os = require('os');
|
|
8
15
|
var path = require('path');
|
|
9
|
-
var
|
|
16
|
+
var promises = require('stream/promises');
|
|
17
|
+
var tar = require('tar');
|
|
10
18
|
var child_process = require('child_process');
|
|
11
19
|
var http = require('http');
|
|
12
20
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
21
|
+
const clientId = 'medplum-cli';
|
|
22
|
+
const redirectUri = 'http://localhost:9615';
|
|
23
|
+
const login = new commander.Command('login');
|
|
24
|
+
const whoami = new commander.Command('whoami');
|
|
25
|
+
login.action(async () => {
|
|
26
|
+
await startLogin(exports.medplum);
|
|
27
|
+
});
|
|
28
|
+
whoami.action(() => {
|
|
29
|
+
printMe(exports.medplum);
|
|
30
|
+
});
|
|
31
|
+
async function startLogin(medplum) {
|
|
32
|
+
await startWebServer(medplum);
|
|
33
|
+
const loginUrl = new URL('/oauth2/authorize', medplum.getBaseUrl());
|
|
34
|
+
loginUrl.searchParams.set('client_id', clientId);
|
|
35
|
+
loginUrl.searchParams.set('redirect_uri', redirectUri);
|
|
36
|
+
loginUrl.searchParams.set('scope', 'openid');
|
|
37
|
+
loginUrl.searchParams.set('response_type', 'code');
|
|
38
|
+
loginUrl.searchParams.set('prompt', 'login');
|
|
39
|
+
await openBrowser(loginUrl.toString());
|
|
40
|
+
}
|
|
41
|
+
async function startWebServer(medplum) {
|
|
42
|
+
const server = http.createServer(async (req, res) => {
|
|
43
|
+
const url = new URL(req.url, 'http://localhost:9615');
|
|
44
|
+
const code = url.searchParams.get('code');
|
|
45
|
+
if (url.pathname === '/' && code) {
|
|
46
|
+
try {
|
|
47
|
+
const profile = await medplum.processCode(code, { clientId, redirectUri });
|
|
48
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
49
|
+
res.end(`Signed in as ${core.getDisplayString(profile)}. You may close this window.`);
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
53
|
+
res.end(`Error: ${core.normalizeErrorString(err)}`);
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
server.close();
|
|
57
|
+
}
|
|
29
58
|
}
|
|
30
59
|
else {
|
|
31
|
-
|
|
60
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
61
|
+
res.end('Not found');
|
|
32
62
|
}
|
|
33
|
-
|
|
63
|
+
}).listen(9615);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Opens a web browser to the specified URL.
|
|
67
|
+
* See: https://hasinthaindrajee.medium.com/browser-sso-for-cli-applications-b0be743fa656
|
|
68
|
+
* @param url The URL to open.
|
|
69
|
+
*/
|
|
70
|
+
async function openBrowser(url) {
|
|
71
|
+
const os$1 = os.platform();
|
|
72
|
+
let cmd = undefined;
|
|
73
|
+
switch (os$1) {
|
|
74
|
+
case 'openbsd':
|
|
75
|
+
case 'linux':
|
|
76
|
+
cmd = `xdg-open '${url}'`;
|
|
77
|
+
break;
|
|
78
|
+
case 'darwin':
|
|
79
|
+
cmd = `open '${url}'`;
|
|
80
|
+
break;
|
|
81
|
+
case 'win32':
|
|
82
|
+
cmd = `cmd /c start "" "${url}"`;
|
|
83
|
+
break;
|
|
84
|
+
default:
|
|
85
|
+
throw new Error('Unsupported platform: ' + os$1);
|
|
34
86
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
87
|
+
child_process.exec(cmd);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Prints the current user and project.
|
|
91
|
+
* @param medplum The Medplum client.
|
|
92
|
+
*/
|
|
93
|
+
function printMe(medplum) {
|
|
94
|
+
const loginState = medplum.getActiveLogin();
|
|
95
|
+
if (loginState) {
|
|
96
|
+
console.log(`Server: ${medplum.getBaseUrl()}`);
|
|
97
|
+
console.log(`Profile: ${loginState.profile?.display} (${loginState.profile?.reference})`);
|
|
98
|
+
console.log(`Project: ${loginState.project?.display} (${loginState.project?.reference})`);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
console.log('Not logged in');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const cloudFormationClient = new clientCloudformation.CloudFormationClient({});
|
|
106
|
+
const cloudFrontClient = new clientCloudfront.CloudFrontClient({});
|
|
107
|
+
const ecsClient = new clientEcs.ECSClient({});
|
|
108
|
+
const s3Client = new clientS3.S3Client({});
|
|
109
|
+
const tagKey = 'medplum:environment';
|
|
110
|
+
/**
|
|
111
|
+
* Returns a list of all AWS CloudFormation stacks (both Medplum and non-Medplum).
|
|
112
|
+
* @returns List of AWS CloudFormation stacks.
|
|
113
|
+
*/
|
|
114
|
+
async function getAllStacks() {
|
|
115
|
+
const listResult = await cloudFormationClient.send(new clientCloudformation.ListStacksCommand({}));
|
|
116
|
+
return (listResult.StackSummaries?.filter((s) => s.StackName && s.StackStatus !== 'DELETE_COMPLETE') || []);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Returns Medplum stack details for the given tag.
|
|
120
|
+
* @param tag The Medplum stack tag.
|
|
121
|
+
* @returns The Medplum stack details.
|
|
122
|
+
*/
|
|
123
|
+
async function getStackByTag(tag) {
|
|
124
|
+
const stackSummaries = await getAllStacks();
|
|
125
|
+
for (const stackSummary of stackSummaries) {
|
|
126
|
+
const stackName = stackSummary.StackName;
|
|
127
|
+
const details = await getStackDetails(stackName);
|
|
128
|
+
if (details?.tag === tag) {
|
|
129
|
+
return details;
|
|
38
130
|
}
|
|
131
|
+
}
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Returns Medplum stack details for the given stack name.
|
|
136
|
+
* @param stackName The CloudFormation stack name.
|
|
137
|
+
* @returns The Medplum stack details.
|
|
138
|
+
*/
|
|
139
|
+
async function getStackDetails(stackName) {
|
|
140
|
+
const describeStacksCommand = new clientCloudformation.DescribeStacksCommand({ StackName: stackName });
|
|
141
|
+
const stackDetails = await cloudFormationClient.send(describeStacksCommand);
|
|
142
|
+
const stack = stackDetails?.Stacks?.[0];
|
|
143
|
+
const medplumTag = stack?.Tags?.find((tag) => tag.Key === tagKey);
|
|
144
|
+
if (!medplumTag) {
|
|
39
145
|
return undefined;
|
|
40
146
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
147
|
+
const stackResources = await cloudFormationClient.send(new clientCloudformation.DescribeStackResourcesCommand({ StackName: stackName }));
|
|
148
|
+
if (!stackResources.StackResources) {
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
const result = {
|
|
152
|
+
stack: stack,
|
|
153
|
+
tag: medplumTag.Value,
|
|
154
|
+
};
|
|
155
|
+
for (const resource of stackResources.StackResources) {
|
|
156
|
+
if (resource.ResourceType === 'AWS::ECS::Cluster') {
|
|
157
|
+
result.ecsCluster = resource;
|
|
158
|
+
}
|
|
159
|
+
else if (resource.ResourceType === 'AWS::ECS::Service') {
|
|
160
|
+
result.ecsService = resource;
|
|
161
|
+
}
|
|
162
|
+
else if (resource.ResourceType === 'AWS::S3::Bucket' &&
|
|
163
|
+
resource.LogicalResourceId?.startsWith('FrontEndAppBucket')) {
|
|
164
|
+
result.appBucket = resource;
|
|
165
|
+
}
|
|
166
|
+
else if (resource.ResourceType === 'AWS::S3::Bucket' &&
|
|
167
|
+
resource.LogicalResourceId?.startsWith('StorageStorageBucket')) {
|
|
168
|
+
result.storageBucket = resource;
|
|
169
|
+
}
|
|
170
|
+
else if (resource.ResourceType === 'AWS::CloudFront::Distribution' &&
|
|
171
|
+
resource.LogicalResourceId?.startsWith('FrontEndAppDistribution')) {
|
|
172
|
+
result.appDistribution = resource;
|
|
44
173
|
}
|
|
45
|
-
|
|
174
|
+
}
|
|
175
|
+
return result;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Prints the given Medplum stack details to stdout.
|
|
179
|
+
* @param details The Medplum stack details.
|
|
180
|
+
*/
|
|
181
|
+
function printStackDetails(details) {
|
|
182
|
+
console.log(`Medplum Tag: ${details.tag}`);
|
|
183
|
+
console.log(`Stack Name: ${details.stack.StackName}`);
|
|
184
|
+
console.log(`Stack ID: ${details.stack.StackId}`);
|
|
185
|
+
console.log(`Status: ${details.stack.StackStatus}`);
|
|
186
|
+
console.log(`ECS Cluster: ${details.ecsCluster?.PhysicalResourceId}`);
|
|
187
|
+
console.log(`ECS Service: ${getEcsServiceName(details.ecsService)}`);
|
|
188
|
+
console.log(`App Bucket: ${details.appBucket?.PhysicalResourceId}`);
|
|
189
|
+
console.log(`Storage Bucket: ${details.storageBucket?.PhysicalResourceId}`);
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Parses the ECS service name from the given AWS ECS service resource.
|
|
193
|
+
* @param resource The AWS ECS service resource.
|
|
194
|
+
* @returns The ECS service name.
|
|
195
|
+
*/
|
|
196
|
+
function getEcsServiceName(resource) {
|
|
197
|
+
return resource?.PhysicalResourceId?.split('/')?.pop() || '';
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The AWS "describe" command prints details about a Medplum CloudFormation stack.
|
|
202
|
+
*
|
|
203
|
+
* @param tag The Medplum stack tag.
|
|
204
|
+
*/
|
|
205
|
+
async function describeStacksCommand(tag) {
|
|
206
|
+
const details = await getStackByTag(tag);
|
|
207
|
+
if (!details) {
|
|
208
|
+
console.log('Stack not found');
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
printStackDetails(details);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* The AWS "list" command prints summary details about all Medplum CloudFormation stacks.
|
|
216
|
+
*/
|
|
217
|
+
async function listStacksCommand() {
|
|
218
|
+
const stackSummaries = await getAllStacks();
|
|
219
|
+
for (const stackSummary of stackSummaries) {
|
|
220
|
+
const stackName = stackSummary.StackName;
|
|
221
|
+
const details = await getStackDetails(stackName);
|
|
222
|
+
if (!details) {
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
printStackDetails(details);
|
|
226
|
+
console.log('');
|
|
46
227
|
}
|
|
47
228
|
}
|
|
48
229
|
|
|
@@ -125,8 +306,9 @@ function readBotConfigs(botName) {
|
|
|
125
306
|
}
|
|
126
307
|
return botConfigs;
|
|
127
308
|
}
|
|
128
|
-
function readConfig() {
|
|
129
|
-
const
|
|
309
|
+
function readConfig(tagName) {
|
|
310
|
+
const fileName = tagName ? `medplum.${tagName}.config.json` : 'medplum.config.json';
|
|
311
|
+
const content = readFileContents(fileName);
|
|
130
312
|
if (!content) {
|
|
131
313
|
return undefined;
|
|
132
314
|
}
|
|
@@ -150,6 +332,265 @@ function addBotToConfig(botConfig) {
|
|
|
150
332
|
function escapeRegex(str) {
|
|
151
333
|
return str.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&');
|
|
152
334
|
}
|
|
335
|
+
/**
|
|
336
|
+
* Creates a safe tar extractor that limits the number of files and total size.
|
|
337
|
+
*
|
|
338
|
+
* Expanding archive files without controlling resource consumption is security-sensitive
|
|
339
|
+
*
|
|
340
|
+
* See: https://sonarcloud.io/organizations/medplum/rules?open=typescript%3AS5042&rule_key=typescript%3AS5042
|
|
341
|
+
*
|
|
342
|
+
* @param destinationDir The destination directory where all files will be extracted.
|
|
343
|
+
* @returns A tar file extractor.
|
|
344
|
+
*/
|
|
345
|
+
function safeTarExtractor(destinationDir) {
|
|
346
|
+
const MAX_FILES = 100;
|
|
347
|
+
const MAX_SIZE = 10 * 1024 * 1024; // 10 MB
|
|
348
|
+
let fileCount = 0;
|
|
349
|
+
let totalSize = 0;
|
|
350
|
+
return tar.x({
|
|
351
|
+
cwd: destinationDir,
|
|
352
|
+
filter: (_path, entry) => {
|
|
353
|
+
fileCount++;
|
|
354
|
+
if (fileCount > MAX_FILES) {
|
|
355
|
+
throw new Error('Tar extractor reached max number of files');
|
|
356
|
+
}
|
|
357
|
+
totalSize += entry.size;
|
|
358
|
+
if (totalSize > MAX_SIZE) {
|
|
359
|
+
throw new Error('Tar extractor reached max size');
|
|
360
|
+
}
|
|
361
|
+
return true;
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* The AWS "update-app" command updates the Medplum app in a Medplum CloudFormation stack to the latest version.
|
|
368
|
+
* @param tag The Medplum stack tag.
|
|
369
|
+
*/
|
|
370
|
+
async function updateAppCommand(tag) {
|
|
371
|
+
const config = readConfig(tag);
|
|
372
|
+
if (!config) {
|
|
373
|
+
console.log('Config not found');
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const details = await getStackByTag(tag);
|
|
377
|
+
if (!details) {
|
|
378
|
+
console.log('Stack not found');
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
const appBucket = details.appBucket;
|
|
382
|
+
if (!appBucket) {
|
|
383
|
+
console.log('App bucket not found');
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
const tmpDir = await downloadNpmPackage('@medplum/app', 'latest');
|
|
387
|
+
// Replace variables in the app
|
|
388
|
+
replaceVariables(tmpDir, {
|
|
389
|
+
MEDPLUM_BASE_URL: config.baseUrl,
|
|
390
|
+
MEDPLUM_CLIENT_ID: config.clientId || '',
|
|
391
|
+
GOOGLE_CLIENT_ID: config.googleClientId || '',
|
|
392
|
+
RECAPTCHA_SITE_KEY: config.recaptchaSiteKey || '',
|
|
393
|
+
MEDPLUM_REGISTER_ENABLED: config.registerEnabled ? 'true' : 'false',
|
|
394
|
+
});
|
|
395
|
+
// Upload the app to S3 with correct content-type and cache-control
|
|
396
|
+
await uploadAppToS3(tmpDir, appBucket.PhysicalResourceId);
|
|
397
|
+
// Create a CloudFront invalidation to clear any cached resources
|
|
398
|
+
if (details.appDistribution?.PhysicalResourceId) {
|
|
399
|
+
await createInvalidation(details.appDistribution.PhysicalResourceId);
|
|
400
|
+
}
|
|
401
|
+
console.log('Done');
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Returns NPM package metadata for a given package name.
|
|
405
|
+
* See: https://github.com/npm/registry/blob/master/docs/REGISTRY-API.md#getpackageversion
|
|
406
|
+
* @param packageName The npm package name.
|
|
407
|
+
*/
|
|
408
|
+
async function getNpmPackageMetadata(packageName, version) {
|
|
409
|
+
const url = `https://registry.npmjs.org/${packageName}/${version}`;
|
|
410
|
+
const response = await fetch$1(url);
|
|
411
|
+
return response.json();
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Downloads and extracts an NPM package.
|
|
415
|
+
* @param packageName The NPM package name.
|
|
416
|
+
* @param version The NPM package version or "latest".
|
|
417
|
+
* @returns Path to temporary directory where the package was downloaded and extracted.
|
|
418
|
+
*/
|
|
419
|
+
async function downloadNpmPackage(packageName, version) {
|
|
420
|
+
const packageMetadata = await getNpmPackageMetadata(packageName, version);
|
|
421
|
+
const tarballUrl = packageMetadata.dist.tarball;
|
|
422
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tarball-'));
|
|
423
|
+
try {
|
|
424
|
+
const response = await fetch$1(tarballUrl);
|
|
425
|
+
const extractor = safeTarExtractor(tmpDir);
|
|
426
|
+
await promises.pipeline(response.body, extractor);
|
|
427
|
+
return path.join(tmpDir, 'package', 'dist');
|
|
428
|
+
}
|
|
429
|
+
catch (error) {
|
|
430
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
431
|
+
throw error;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Replaces variables in all JS files in the given folder.
|
|
436
|
+
* @param folderName The folder name of the files.
|
|
437
|
+
* @param replacements The collection of variable placeholders and replacements.
|
|
438
|
+
*/
|
|
439
|
+
function replaceVariables(folderName, replacements) {
|
|
440
|
+
for (const item of fs.readdirSync(folderName, { withFileTypes: true })) {
|
|
441
|
+
const itemPath = path.join(folderName, item.name);
|
|
442
|
+
if (item.isDirectory()) {
|
|
443
|
+
replaceVariables(itemPath, replacements);
|
|
444
|
+
}
|
|
445
|
+
else if (item.isFile() && itemPath.endsWith('.js')) {
|
|
446
|
+
replaceVariablesInFile(itemPath, replacements);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Replaces variables in the JS file.
|
|
452
|
+
* @param fileName The file name.
|
|
453
|
+
* @param replacements The collection of variable placeholders and replacements.
|
|
454
|
+
*/
|
|
455
|
+
function replaceVariablesInFile(fileName, replacements) {
|
|
456
|
+
let contents = fs.readFileSync(fileName, 'utf-8');
|
|
457
|
+
for (const [placeholder, replacement] of Object.entries(replacements)) {
|
|
458
|
+
contents = contents.replaceAll(`process.env.${placeholder}`, `'${replacement}'`);
|
|
459
|
+
}
|
|
460
|
+
fs.writeFileSync(fileName, contents);
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Uploads the app to S3.
|
|
464
|
+
* Ensures correct content-type and cache-control for each file.
|
|
465
|
+
* @param tmpDir The temporary directory where the app is located.
|
|
466
|
+
* @param bucketName The destination S3 bucket name.
|
|
467
|
+
*/
|
|
468
|
+
async function uploadAppToS3(tmpDir, bucketName) {
|
|
469
|
+
// Manually iterate and upload files
|
|
470
|
+
// Automatic content-type detection is not reliable on Microsoft Windows
|
|
471
|
+
// So we explicitly set content-type
|
|
472
|
+
const uploadPatterns = [
|
|
473
|
+
// Cached
|
|
474
|
+
// These files generally have a hash, so they can be cached forever
|
|
475
|
+
// It is important to upload them first to avoid broken references from index.html
|
|
476
|
+
['css/**/*.css', 'text/css', true],
|
|
477
|
+
['css/**/*.css.map', 'application/json', true],
|
|
478
|
+
['img/**/*.png', 'image/png', true],
|
|
479
|
+
['img/**/*.svg', 'image/svg+xml', true],
|
|
480
|
+
['js/**/*.js', 'application/javascript', true],
|
|
481
|
+
['js/**/*.js.map', 'application/json', true],
|
|
482
|
+
['js/**/*.txt', 'text/plain', true],
|
|
483
|
+
['favicon.ico', 'image/vnd.microsoft.icon', true],
|
|
484
|
+
['robots.txt', 'text/plain', true],
|
|
485
|
+
['workbox-*.js', 'application/javascript', true],
|
|
486
|
+
['workbox-*.js.map', 'application/json', true],
|
|
487
|
+
// Not cached
|
|
488
|
+
['manifest.webmanifest', 'application/manifest+json', false],
|
|
489
|
+
['service-worker.js', 'application/javascript', false],
|
|
490
|
+
['service-worker.js.map', 'application/json', false],
|
|
491
|
+
['index.html', 'text/html', false],
|
|
492
|
+
];
|
|
493
|
+
for (const uploadPattern of uploadPatterns) {
|
|
494
|
+
await uploadFolderToS3({
|
|
495
|
+
rootDir: tmpDir,
|
|
496
|
+
bucketName,
|
|
497
|
+
fileNamePattern: uploadPattern[0],
|
|
498
|
+
contentType: uploadPattern[1],
|
|
499
|
+
cached: uploadPattern[2],
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Uploads a directory of files to S3.
|
|
505
|
+
* @param options The upload options such as bucket name, content type, and cache control.
|
|
506
|
+
*/
|
|
507
|
+
async function uploadFolderToS3(options) {
|
|
508
|
+
const items = fastGlob.sync(options.fileNamePattern, { cwd: options.rootDir });
|
|
509
|
+
for (const item of items) {
|
|
510
|
+
await uploadFileToS3(path.join(options.rootDir, item), options);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Uploads a file to S3.
|
|
515
|
+
* @param filePath The file path.
|
|
516
|
+
* @param options The upload options such as bucket name, content type, and cache control.
|
|
517
|
+
*/
|
|
518
|
+
async function uploadFileToS3(filePath, options) {
|
|
519
|
+
const fileStream = fs.createReadStream(filePath);
|
|
520
|
+
const s3Key = filePath
|
|
521
|
+
.substring(options.rootDir.length + 1)
|
|
522
|
+
.split(path.sep)
|
|
523
|
+
.join('/');
|
|
524
|
+
const putObjectParams = {
|
|
525
|
+
Bucket: options.bucketName,
|
|
526
|
+
Key: s3Key,
|
|
527
|
+
Body: fileStream,
|
|
528
|
+
ContentType: options.contentType,
|
|
529
|
+
CacheControl: options.cached ? 'public, max-age=31536000' : 'no-cache, no-store, must-revalidate',
|
|
530
|
+
};
|
|
531
|
+
console.log(`Uploading ${s3Key} to ${options.bucketName}...`);
|
|
532
|
+
await s3Client.send(new clientS3.PutObjectCommand(putObjectParams));
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Creates a CloudFront invalidation to clear the cache for all files.
|
|
536
|
+
* This is not strictly necessary, but it helps to ensure that the latest version of the app is served.
|
|
537
|
+
* In a perfect world, every deploy is clean, and hashed resources should be cached forever.
|
|
538
|
+
* However, we do not recalculate hashes after variable replacements.
|
|
539
|
+
* So if variables change, we need to invalidate the cache.
|
|
540
|
+
* @param distributionId The CloudFront distribution ID.
|
|
541
|
+
*/
|
|
542
|
+
async function createInvalidation(distributionId) {
|
|
543
|
+
const response = await cloudFrontClient.send(new clientCloudfront.CreateInvalidationCommand({
|
|
544
|
+
DistributionId: distributionId,
|
|
545
|
+
InvalidationBatch: {
|
|
546
|
+
CallerReference: `invalidate-all-${Date.now()}`,
|
|
547
|
+
Paths: {
|
|
548
|
+
Quantity: 1,
|
|
549
|
+
Items: ['/*'],
|
|
550
|
+
},
|
|
551
|
+
},
|
|
552
|
+
}));
|
|
553
|
+
console.log(`Created invalidation with ID: ${response.Invalidation?.Id}`);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* The AWS "update-server" command updates the Medplum server in a Medplum CloudFormation stack.
|
|
558
|
+
* @param tag The Medplum stack tag.
|
|
559
|
+
* @returns
|
|
560
|
+
*/
|
|
561
|
+
async function updateServerCommand(tag) {
|
|
562
|
+
const details = await getStackByTag(tag);
|
|
563
|
+
if (!details) {
|
|
564
|
+
console.log('Stack not found');
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
const ecsCluster = details.ecsCluster?.PhysicalResourceId;
|
|
568
|
+
if (!ecsCluster) {
|
|
569
|
+
console.log('ECS Cluster not found');
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
const ecsService = getEcsServiceName(details.ecsService);
|
|
573
|
+
if (!ecsService) {
|
|
574
|
+
console.log('ECS Service not found');
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
await ecsClient.send(new clientEcs.UpdateServiceCommand({
|
|
578
|
+
cluster: ecsCluster,
|
|
579
|
+
service: ecsService,
|
|
580
|
+
forceNewDeployment: true,
|
|
581
|
+
}));
|
|
582
|
+
console.log(`Service "${ecsService}" updated successfully.`);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const aws = new commander.Command('aws').description('Commands to manage AWS resources');
|
|
586
|
+
aws.command('list').description('List Medplum AWS CloudFormation stacks').action(listStacksCommand);
|
|
587
|
+
aws
|
|
588
|
+
.command('describe')
|
|
589
|
+
.description('Describe a Medplum AWS CloudFormation stack by tag')
|
|
590
|
+
.argument('<tag>')
|
|
591
|
+
.action(describeStacksCommand);
|
|
592
|
+
aws.command('update-server').description('Update the server image').argument('<tag>').action(updateServerCommand);
|
|
593
|
+
aws.command('update-app').description('Update the app site').argument('<tag>').action(updateAppCommand);
|
|
153
594
|
|
|
154
595
|
const bot = new commander.Command('bot');
|
|
155
596
|
// Commands to deprecate
|
|
@@ -208,86 +649,86 @@ createBotDeprecate
|
|
|
208
649
|
await createBot(exports.medplum, [botName, projectId, sourceFile, distFile]);
|
|
209
650
|
});
|
|
210
651
|
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
});
|
|
218
|
-
whoami.action(() => {
|
|
219
|
-
printMe(exports.medplum);
|
|
652
|
+
const project = new commander.Command('project');
|
|
653
|
+
project
|
|
654
|
+
.command('list')
|
|
655
|
+
.description('List of current projects')
|
|
656
|
+
.action(async () => {
|
|
657
|
+
projectList(exports.medplum);
|
|
220
658
|
});
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
loginUrl.searchParams.set('response_type', 'code');
|
|
228
|
-
await openBrowser(loginUrl.toString());
|
|
229
|
-
}
|
|
230
|
-
async function startWebServer(medplum) {
|
|
231
|
-
const server = http.createServer(async (req, res) => {
|
|
232
|
-
const url = new URL(req.url, 'http://localhost:9615');
|
|
233
|
-
const code = url.searchParams.get('code');
|
|
234
|
-
if (url.pathname === '/' && code) {
|
|
235
|
-
try {
|
|
236
|
-
const profile = await medplum.processCode(code, { clientId, redirectUri });
|
|
237
|
-
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
238
|
-
res.end(`Signed in as ${core.getDisplayString(profile)}. You may close this window.`);
|
|
239
|
-
}
|
|
240
|
-
catch (err) {
|
|
241
|
-
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
242
|
-
res.end(`Error: ${core.normalizeErrorString(err)}`);
|
|
243
|
-
}
|
|
244
|
-
finally {
|
|
245
|
-
server.close();
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
else {
|
|
249
|
-
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
250
|
-
res.end('Not found');
|
|
251
|
-
}
|
|
252
|
-
}).listen(9615);
|
|
659
|
+
function projectList(medplum) {
|
|
660
|
+
const logins = medplum.getLogins();
|
|
661
|
+
const projects = logins
|
|
662
|
+
.map((login) => `${login.project.display} (${login.project.reference})`)
|
|
663
|
+
.join('\n\n');
|
|
664
|
+
console.log(projects);
|
|
253
665
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
let cmd = undefined;
|
|
262
|
-
switch (os$1) {
|
|
263
|
-
case 'openbsd':
|
|
264
|
-
case 'linux':
|
|
265
|
-
cmd = `xdg-open '${url}'`;
|
|
266
|
-
break;
|
|
267
|
-
case 'darwin':
|
|
268
|
-
cmd = `open '${url}'`;
|
|
269
|
-
break;
|
|
270
|
-
case 'win32':
|
|
271
|
-
cmd = `cmd /c start "" "${url}"`;
|
|
272
|
-
break;
|
|
273
|
-
default:
|
|
274
|
-
throw new Error('Unsupported platform: ' + os$1);
|
|
666
|
+
project
|
|
667
|
+
.command('current')
|
|
668
|
+
.description('Project you are currently on')
|
|
669
|
+
.action(() => {
|
|
670
|
+
const login = exports.medplum.getActiveLogin();
|
|
671
|
+
if (!login) {
|
|
672
|
+
throw new Error('Unauthenticated: run `npx medplum login` to login');
|
|
275
673
|
}
|
|
276
|
-
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
674
|
+
console.log(`${login.project.display} (${login.project.reference})`);
|
|
675
|
+
});
|
|
676
|
+
project
|
|
677
|
+
.command('switch')
|
|
678
|
+
.description('Switching to another project from the current one')
|
|
679
|
+
.argument('<projectId>')
|
|
680
|
+
.action(async (projectId) => {
|
|
681
|
+
await switchProject(exports.medplum, projectId);
|
|
682
|
+
});
|
|
683
|
+
project
|
|
684
|
+
.command('invite')
|
|
685
|
+
.description('Invite a member to your current project (run npx medplum project current to confirm)')
|
|
686
|
+
.arguments('<firstName> <lastName> <email>')
|
|
687
|
+
.option('--send-email', 'If you want to send the email when inviting the user')
|
|
688
|
+
.option('--admin', 'If the user you are inviting is an admin')
|
|
689
|
+
.addOption(new commander.Option('-r, --role <role>', 'Role of user')
|
|
690
|
+
.choices(['Practitioner', 'Patient', 'RelatedPerson'])
|
|
691
|
+
.default('Practitioner'))
|
|
692
|
+
.action(async (firstName, lastName, email, options) => {
|
|
693
|
+
const login = exports.medplum.getActiveLogin();
|
|
694
|
+
if (!login) {
|
|
695
|
+
throw new Error('Unauthenticated: run `npx medplum login` to login');
|
|
696
|
+
}
|
|
697
|
+
if (!login.project?.reference) {
|
|
698
|
+
throw new Error('No current project to invite user to');
|
|
699
|
+
}
|
|
700
|
+
const projectId = login.project.reference.split('/')[1];
|
|
701
|
+
const inviteBody = {
|
|
702
|
+
resourceType: options.role,
|
|
703
|
+
firstName,
|
|
704
|
+
lastName,
|
|
705
|
+
email,
|
|
706
|
+
sendEmail: !!options.sendEmail,
|
|
707
|
+
admin: !!options.admin,
|
|
708
|
+
};
|
|
709
|
+
await inviteUser(projectId, inviteBody);
|
|
710
|
+
});
|
|
711
|
+
async function switchProject(medplum, projectId) {
|
|
712
|
+
const logins = medplum.getLogins();
|
|
713
|
+
const login = logins.find((login) => login.project?.reference?.includes(projectId));
|
|
714
|
+
if (!login) {
|
|
715
|
+
console.log(`Error: project ${projectId} not found. Make sure you are added as a user to this project`);
|
|
288
716
|
}
|
|
289
717
|
else {
|
|
290
|
-
|
|
718
|
+
await medplum.setActiveLogin(login);
|
|
719
|
+
console.log(`Switched to project ${projectId}\n`);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
async function inviteUser(projectId, inviteBody) {
|
|
723
|
+
try {
|
|
724
|
+
await exports.medplum.invite(projectId, inviteBody);
|
|
725
|
+
if (inviteBody.sendEmail) {
|
|
726
|
+
console.log('Email sent');
|
|
727
|
+
}
|
|
728
|
+
console.log('See your users at https://app.medplum.com/admin/users');
|
|
729
|
+
}
|
|
730
|
+
catch (err) {
|
|
731
|
+
console.log('Error while sending invite ' + err);
|
|
291
732
|
}
|
|
292
733
|
}
|
|
293
734
|
|
|
@@ -303,17 +744,12 @@ get
|
|
|
303
744
|
.argument('<url>', 'Resource/$id')
|
|
304
745
|
.option('--as-transaction', 'Print out the bundle as a transaction type')
|
|
305
746
|
.action(async (url, options) => {
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
prettyPrint(core.convertToTransactionBundle(response));
|
|
310
|
-
}
|
|
311
|
-
else {
|
|
312
|
-
prettyPrint(response);
|
|
313
|
-
}
|
|
747
|
+
const response = await exports.medplum.get(cleanUrl(url));
|
|
748
|
+
if (options.asTransaction) {
|
|
749
|
+
prettyPrint(core.convertToTransactionBundle(response));
|
|
314
750
|
}
|
|
315
|
-
|
|
316
|
-
|
|
751
|
+
else {
|
|
752
|
+
prettyPrint(response);
|
|
317
753
|
}
|
|
318
754
|
});
|
|
319
755
|
patch.arguments('<url> <body>').action(async (url, body) => {
|
|
@@ -346,46 +782,39 @@ function cleanUrl(input) {
|
|
|
346
782
|
return 'fhir/R4/' + input;
|
|
347
783
|
}
|
|
348
784
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
projectList(exports.medplum);
|
|
355
|
-
});
|
|
356
|
-
function projectList(medplum) {
|
|
357
|
-
const logins = medplum.getLogins();
|
|
358
|
-
const projects = logins
|
|
359
|
-
.map((login) => `${login.project.display} (${login.project.reference})`)
|
|
360
|
-
.join('\n\n');
|
|
361
|
-
console.log(projects);
|
|
362
|
-
}
|
|
363
|
-
project
|
|
364
|
-
.command('current')
|
|
365
|
-
.description('Project you are currently on')
|
|
366
|
-
.action(() => {
|
|
367
|
-
const login = exports.medplum.getActiveLogin();
|
|
368
|
-
if (!login) {
|
|
369
|
-
throw new Error('Unauthenticated: run `npx medplum login` to login');
|
|
785
|
+
class FileSystemStorage extends core.ClientStorage {
|
|
786
|
+
constructor() {
|
|
787
|
+
super();
|
|
788
|
+
this.dirName = path.resolve(os.homedir(), '.medplum');
|
|
789
|
+
this.fileName = path.resolve(this.dirName, 'credentials');
|
|
370
790
|
}
|
|
371
|
-
|
|
372
|
-
});
|
|
373
|
-
project
|
|
374
|
-
.command('switch')
|
|
375
|
-
.description('Switching to another project from the current one')
|
|
376
|
-
.argument('<projectId>')
|
|
377
|
-
.action(async (projectId) => {
|
|
378
|
-
await switchProject(exports.medplum, projectId);
|
|
379
|
-
});
|
|
380
|
-
async function switchProject(medplum, projectId) {
|
|
381
|
-
const logins = medplum.getLogins();
|
|
382
|
-
const login = logins.find((login) => login.project?.reference?.includes(projectId));
|
|
383
|
-
if (!login) {
|
|
384
|
-
console.log(`Error: project ${projectId} not found. Make sure you are added as a user to this project`);
|
|
791
|
+
clear() {
|
|
792
|
+
this.writeFile({});
|
|
385
793
|
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
794
|
+
getString(key) {
|
|
795
|
+
return this.readFile()?.[key];
|
|
796
|
+
}
|
|
797
|
+
setString(key, value) {
|
|
798
|
+
const data = this.readFile() || {};
|
|
799
|
+
if (value) {
|
|
800
|
+
data[key] = value;
|
|
801
|
+
}
|
|
802
|
+
else {
|
|
803
|
+
delete data[key];
|
|
804
|
+
}
|
|
805
|
+
this.writeFile(data);
|
|
806
|
+
}
|
|
807
|
+
readFile() {
|
|
808
|
+
if (fs.existsSync(this.fileName)) {
|
|
809
|
+
return JSON.parse(fs.readFileSync(this.fileName, 'utf8'));
|
|
810
|
+
}
|
|
811
|
+
return undefined;
|
|
812
|
+
}
|
|
813
|
+
writeFile(data) {
|
|
814
|
+
if (!fs.existsSync(this.dirName)) {
|
|
815
|
+
fs.mkdirSync(this.dirName);
|
|
816
|
+
}
|
|
817
|
+
fs.writeFileSync(this.fileName, JSON.stringify(data, null, 2), 'utf8');
|
|
389
818
|
}
|
|
390
819
|
}
|
|
391
820
|
|
|
@@ -400,7 +829,7 @@ async function main(medplumClient, argv) {
|
|
|
400
829
|
}
|
|
401
830
|
try {
|
|
402
831
|
const index = new commander.Command('medplum').description('Command to access Medplum CLI');
|
|
403
|
-
index.version(
|
|
832
|
+
index.version(core.MEDPLUM_VERSION);
|
|
404
833
|
// Auth commands
|
|
405
834
|
index.addCommand(login);
|
|
406
835
|
index.addCommand(whoami);
|
|
@@ -418,6 +847,8 @@ async function main(medplumClient, argv) {
|
|
|
418
847
|
index.addCommand(saveBotDeprecate);
|
|
419
848
|
index.addCommand(deployBotDeprecate);
|
|
420
849
|
index.addCommand(createBotDeprecate);
|
|
850
|
+
// AWS commands
|
|
851
|
+
index.addCommand(aws);
|
|
421
852
|
await index.parseAsync(argv);
|
|
422
853
|
}
|
|
423
854
|
catch (err) {
|
|
@@ -427,9 +858,17 @@ async function main(medplumClient, argv) {
|
|
|
427
858
|
if (require.main === module) {
|
|
428
859
|
dotenv.config();
|
|
429
860
|
const baseUrl = process.env['MEDPLUM_BASE_URL'] || 'https://api.medplum.com/';
|
|
430
|
-
const medplumClient = new core.MedplumClient({
|
|
861
|
+
const medplumClient = new core.MedplumClient({
|
|
862
|
+
fetch,
|
|
863
|
+
baseUrl,
|
|
864
|
+
storage: new FileSystemStorage(),
|
|
865
|
+
onUnauthenticated: onUnauthenticated,
|
|
866
|
+
});
|
|
431
867
|
main(medplumClient, process.argv).catch((err) => console.error('Unhandled error:', err));
|
|
432
868
|
}
|
|
869
|
+
function onUnauthenticated() {
|
|
870
|
+
console.log('Unauthenticated: run `npx medplum login` to sign in');
|
|
871
|
+
}
|
|
433
872
|
|
|
434
873
|
exports.main = main;
|
|
435
874
|
//# sourceMappingURL=index.cjs.map
|