Repository: FHIR/sushi
Pull request #1442 (2024-03-18 11:34:04)

Override sushi-config options from command line
#1439 

Added command line option to override some elements in sushi-config.yaml:
```
 -c, --config <config>    override elements in sushi-config.yaml (supported: 'version', 'status', 'releaselabel') (eg: --config status:draft)
```

cmoesel: Thank you for the contribution, @LodewijkSioen! We're currently discussing this with the team. We're trying to decide if the way you've done it is best (which is very clear but adds _three_ new options to the CLI) or if it might be better to introduce a _single_ option for which the thing you're overriding is part of the value (e.g., `-ig version:1.2.3 -ig status:draft -ig releaselable:qa-draft`). The single option is a tiny bit harder to use, but allows for easily supporting other overrides without continuing to increase the number of different option flags. Do you have any thoughts on this?
LodewijkSioen: @cmoesel I'm ok with that. I can imagine the option bloat. However, I don't see a way to do that using [commander.js](https://github.com/tj/commander.js?tab=readme-ov-file#options) except [custom option parsing](https://github.com/tj/commander.js?tab=readme-ov-file#options). This would introduce a bunch of parsing logic. Alternatively, the IG override options could be a json string.

One drawback is that the built-in `--help` documentation will be less useful.
cmoesel: Hi @LodewijkSioen. I apologize for the delay in responding. We've discussed this some more as a team, and we do think that this feature would work better as a single option (perhaps `-c` / `--config` since you're overriding config data). We feel that this is actually more user-friendly because having too many command-line options can be overwhelming.

I don't think having the user express overrides as a JSON string would be very user-friendly. But I also don't think that you need to implement this using commander's custom option parsing. I'd suggest that you specify the option as a [variadic option](https://github.com/tj/commander.js?tab=readme-ov-file#variadic-option). This would allow users to pass the option multiple times (e.g., `-c version:1.2.3 -c status:draft`), which makes the resulting option value an array (e.g., `['version:1.2.3', 'status:draft']`). Then in [updateConfig](https://github.com/FHIR/sushi/blob/310cd4c7a9c0c8aa5555df8a4bb226ce613e45cc/src/utils/Processing.ts#L225-L243), you just iterate that array, split each item on `:` to get your key and value, and set it in the config (with perhaps a gating function to ensure only allowed keys are used).

How does that sound to you?
LodewijkSioen: Sounds good to me. This seems more elegant and in line with how most command line options work. I can take a stab at it the week after next.
LodewijkSioen: Prettier should be fixed now.
LodewijkSioen: Release notes updated

Patch:
diff --git a/src/app.ts b/src/app.ts
index b027504..d997af2 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -32,7 +32,8 @@ import {
   setIgnoredWarnings,
   getLocalSushiVersion,
   checkSushiVersion,
-  writeFSHIndex
+  writeFSHIndex,
+  updateConfig
 } from './utils';
 
 const FSH_VERSION = '3.0.0-ballot';
@@ -71,6 +72,14 @@ async function app() {
       false
     )
     .option('-s, --snapshot', 'generate snapshot in Structure Definition output', false)
+    .option(
+      '-c, --config <config>',
+      "override elements in sushi-config.yaml (supported: 'version', 'status', 'releaselabel') (eg: --config status:draft)",
+      (value: string, previous = {}) => {
+        const [k, ...v] = value.split(':');
+        return Object.assign(previous, { [k]: v.join(':') });
+      }
+    )
     .action(async function (projectPath, options) {
       setLogLevel(options);
       await runBuild(projectPath, options, program.helpInformation()).catch(logUnexpectedError);
@@ -173,6 +182,9 @@ async function runBuild(input: string, program: OptionValues, helpText: string)
   if (program.out) {
     logger.info(`  --out ${path.resolve(program.out)}`);
   }
+  if (program.config) {
+    Object.entries(program.config).forEach(([k, v]) => logger.info(`  --config ${k}:${v}`));
+  }
   logger.info(`  ${path.resolve(input || '.')}`);
 
   const sushiVersions = await checkSushiVersion();
@@ -251,6 +263,7 @@ async function runBuild(input: string, program: OptionValues, helpText: string)
       process.exit(0);
     }
     config = readConfig(originalInput);
+    updateConfig(config, program);
     tank = fillTank(rawFSH, config);
   } catch (e) {
     // If no errors have been logged yet, log this exception so the user knows why we're exiting
diff --git a/src/utils/Processing.ts b/src/utils/Processing.ts
index 0c7da60..4ca3241 100644
--- a/src/utils/Processing.ts
+++ b/src/utils/Processing.ts
@@ -25,6 +25,7 @@ import { axiosGet } from './axiosUtils';
 import { ImplementationGuideDependsOn } from '../fhirtypes';
 import { FHIRVersionName, getFHIRVersionInfo } from '../utils/FHIRVersionUtils';
 import table from 'text-table';
+import { OptionValues } from 'commander';
 
 const EXT_PKG_TO_FHIR_PKG_MAP: { [key: string]: string } = {
   'hl7.fhir.extensions.r2': 'hl7.fhir.r2.core#1.0.2',
@@ -221,6 +222,28 @@ export function readConfig(input: string): Configuration {
   return config;
 }
 
+export function updateConfig(config: Configuration, program: OptionValues): void {
+  if (program.config) {
+    if (program.config.version) {
+      config.version = program.config.version;
+    }
+    if (program.config.status) {
+      config.status = program.config.status;
+    }
+    if (program.config.releaselabel) {
+      const labelIndex = config.parameters.findIndex(p => p.code === 'releaselabel');
+      if (labelIndex !== -1) {
+        config.parameters[labelIndex].value = program.config.releaselabel;
+      } else {
+        config.parameters.push({
+          code: 'releaselabel',
+          value: program.config.releaselabel
+        });
+      }
+    }
+  }
+}
+
 export async function updateExternalDependencies(config: Configuration): Promise<boolean> {
   // only try to update if we got the config from sushi-config.yaml, and not from an IG
   const changedVersions: Map<string, string> = new Map();


Test patch:
diff --git a/test/utils/Processing.test.ts b/test/utils/Processing.test.ts
index feabb8d..e4853b6 100644
--- a/test/utils/Processing.test.ts
+++ b/test/utils/Processing.test.ts
@@ -31,7 +31,8 @@ import {
   getLocalSushiVersion,
   getLatestSushiVersion,
   checkSushiVersion,
-  writeFSHIndex
+  writeFSHIndex,
+  updateConfig
 } from '../../src/utils/Processing';
 import { FHIRDefinitions } from '../../src/fhirdefs';
 import { Package } from '../../src/export';
@@ -485,6 +486,156 @@ describe('Processing', () => {
     });
   });
 
+  describe('#updateConfig', () => {
+    it('should update the config with the command line options', () => {
+      const input = path.join(__dirname, 'fixtures', 'valid-yaml');
+      const config = readConfig(input);
+      updateConfig(config, {
+        config: { version: '1.2.3', status: 'draft', releaselabel: 'qa-preview' }
+      });
+
+      expect(config).toEqual({
+        filePath: path.join(__dirname, 'fixtures', 'valid-yaml', 'sushi-config.yaml'),
+        id: 'sushi-test',
+        packageId: 'sushi-test',
+        canonical: 'http://hl7.org/fhir/sushi-test',
+        url: 'http://hl7.org/fhir/sushi-test/ImplementationGuide/sushi-test',
+        version: '1.2.3',
+        name: 'FSHTestIG',
+        title: 'FSH Test IG',
+        status: 'draft',
+        contact: [
+          {
+            name: 'Bill Cod',
+            telecom: [
+              { system: 'url', value: 'https://capecodfishermen.org/' },
+              { system: 'email', value: 'cod@reef.gov' }
+            ]
+          }
+        ],
+        description: 'Provides a simple example of how FSH can be used to create an IG',
+        license: 'CC0-1.0',
+        fhirVersion: ['4.0.1'],
+        dependencies: [
+          { packageId: 'hl7.fhir.us.core', version: '3.1.0' },
+          { packageId: 'hl7.fhir.uv.vhdir', version: 'current' }
+        ],
+        FSHOnly: false,
+        applyExtensionMetadataToRoot: true,
+        instanceOptions: { setMetaProfile: 'always', setId: 'always', manualSliceOrdering: false },
+        parameters: [
+          {
+            code: 'copyrightyear',
+            value: '2020'
+          },
+          {
+            code: 'releaselabel',
+            value: 'qa-preview'
+          }
+        ]
+      });
+    });
+
+    it('should ignore unsupported elements', () => {
+      const input = path.join(__dirname, 'fixtures', 'valid-yaml');
+      const config = readConfig(input);
+      updateConfig(config, {
+        config: { version: '1.2.3', something: 'unsupported' }
+      });
+
+      expect(config).toEqual({
+        filePath: path.join(__dirname, 'fixtures', 'valid-yaml', 'sushi-config.yaml'),
+        id: 'sushi-test',
+        packageId: 'sushi-test',
+        canonical: 'http://hl7.org/fhir/sushi-test',
+        url: 'http://hl7.org/fhir/sushi-test/ImplementationGuide/sushi-test',
+        version: '1.2.3',
+        name: 'FSHTestIG',
+        title: 'FSH Test IG',
+        status: 'active',
+        contact: [
+          {
+            name: 'Bill Cod',
+            telecom: [
+              { system: 'url', value: 'https://capecodfishermen.org/' },
+              { system: 'email', value: 'cod@reef.gov' }
+            ]
+          }
+        ],
+        description: 'Provides a simple example of how FSH can be used to create an IG',
+        license: 'CC0-1.0',
+        fhirVersion: ['4.0.1'],
+        dependencies: [
+          { packageId: 'hl7.fhir.us.core', version: '3.1.0' },
+          { packageId: 'hl7.fhir.uv.vhdir', version: 'current' }
+        ],
+        FSHOnly: false,
+        applyExtensionMetadataToRoot: true,
+        instanceOptions: { setMetaProfile: 'always', setId: 'always', manualSliceOrdering: false },
+        parameters: [
+          {
+            code: 'copyrightyear',
+            value: '2020'
+          },
+          {
+            code: 'releaselabel',
+            value: 'CI Build'
+          }
+        ]
+      });
+    });
+
+    it('should support values with colons', () => {
+      const input = path.join(__dirname, 'fixtures', 'valid-yaml');
+      const config = readConfig(input);
+      updateConfig(config, {
+        //not a valid semver, just to make a point
+        config: { version: '1.2.3-beta:1' }
+      });
+
+      expect(config).toEqual({
+        filePath: path.join(__dirname, 'fixtures', 'valid-yaml', 'sushi-config.yaml'),
+        id: 'sushi-test',
+        packageId: 'sushi-test',
+        canonical: 'http://hl7.org/fhir/sushi-test',
+        url: 'http://hl7.org/fhir/sushi-test/ImplementationGuide/sushi-test',
+        version: '1.2.3-beta:1',
+        name: 'FSHTestIG',
+        title: 'FSH Test IG',
+        status: 'active',
+        contact: [
+          {
+            name: 'Bill Cod',
+            telecom: [
+              { system: 'url', value: 'https://capecodfishermen.org/' },
+              { system: 'email', value: 'cod@reef.gov' }
+            ]
+          }
+        ],
+        description: 'Provides a simple example of how FSH can be used to create an IG',
+        license: 'CC0-1.0',
+        fhirVersion: ['4.0.1'],
+        dependencies: [
+          { packageId: 'hl7.fhir.us.core', version: '3.1.0' },
+          { packageId: 'hl7.fhir.uv.vhdir', version: 'current' }
+        ],
+        FSHOnly: false,
+        applyExtensionMetadataToRoot: true,
+        instanceOptions: { setMetaProfile: 'always', setId: 'always', manualSliceOrdering: false },
+        parameters: [
+          {
+            code: 'copyrightyear',
+            value: '2020'
+          },
+          {
+            code: 'releaselabel',
+            value: 'CI Build'
+          }
+        ]
+      });
+    });
+  });
+
   describe('#updateExternalDependencies', () => {
     let tempRoot: string;
     let config: Configuration;