Repository: Accenture/sfmc-devtools (js)

Issue:
[FEATURE] allow `execute` and `deploy automation --execute` to alternatively trigger "runOnce"
the default right now is to start automations that have a schedule but that's not always the case.
The user should be able to choose between runOnce or schedule, with one being the default.

Pull request:
Feature/1025 add `schedule` and `deploy --schedule` for automations
# PR details

## What changes did you make? (Give an overview)

- closes #1025



## Checklist

- [x] I have performed a self-review of my own code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] test scripts updated
- [x] Wiki updated (via #1007)


Patch:
diff --git a/docs/dist/documentation.md b/docs/dist/documentation.md
index c8cc127b..57e14a1e 100644
--- a/docs/dist/documentation.md
+++ b/docs/dist/documentation.md
@@ -201,6 +201,9 @@ Provides default functionality that can be overwritten by child metadata type cl
 <dt><a href="#Automation.">Automation.(metadataMap, key)</a> ⇒ <code>Promise.&lt;object&gt;</code></dt>
 <dd><p>helper for <a href="#Automation.execute">execute</a></p>
 </dd>
+<dt><a href="#Automation.">Automation.(metadataEntry)</a> ⇒ <code>Promise.&lt;object&gt;</code></dt>
+<dd><p>helper for <a href="#Automation.execute">execute</a></p>
+</dd>
 <dt><a href="#Automation.">Automation.(metadata)</a> ⇒ <code>Promise.&lt;object&gt;</code></dt>
 <dd><p>helper for <a href="#Automation.pause">pause</a></p>
 </dd>
@@ -516,6 +519,7 @@ main class
     * [.buildDefinition(businessUnit, selectedType, name, market)](#Mcdev.buildDefinition) ⇒ <code>Promise.&lt;void&gt;</code>
     * [.buildDefinitionBulk(listName, type, name)](#Mcdev.buildDefinitionBulk) ⇒ <code>Promise.&lt;void&gt;</code>
     * [.getFilesToCommit(businessUnit, selectedType, keyArr)](#Mcdev.getFilesToCommit) ⇒ <code>Promise.&lt;Array.&lt;string&gt;&gt;</code>
+    * [.schedule(businessUnit, [selectedType], [keys])](#Mcdev.schedule) ⇒ <code>Promise.&lt;boolean&gt;</code>
     * [.execute(businessUnit, [selectedType], [keys])](#Mcdev.execute) ⇒ <code>Promise.&lt;boolean&gt;</code>
     * [.pause(businessUnit, [selectedType], [keys])](#Mcdev.pause) ⇒ <code>Promise.&lt;boolean&gt;</code>
 
@@ -770,6 +774,20 @@ Build a specific metadata file based on a template using a list of bu-market com
 | selectedType | <code>string</code> | supported metadata type |
 | keyArr | <code>Array.&lt;string&gt;</code> | customerkey of the metadata |
 
+<a name="Mcdev.schedule"></a>
+
+### Mcdev.schedule(businessUnit, [selectedType], [keys]) ⇒ <code>Promise.&lt;boolean&gt;</code>
+Schedule an item (shortcut for execute --schedule)
+
+**Kind**: static method of [<code>Mcdev</code>](#Mcdev)  
+**Returns**: <code>Promise.&lt;boolean&gt;</code> - true if all started successfully, false if not  
+
+| Param | Type | Description |
+| --- | --- | --- |
+| businessUnit | <code>string</code> | name of BU |
+| [selectedType] | <code>TYPE.SupportedMetadataTypes</code> | limit to given metadata types |
+| [keys] | <code>Array.&lt;string&gt;</code> | customerkey of the metadata |
+
 <a name="Mcdev.execute"></a>
 
 ### Mcdev.execute(businessUnit, [selectedType], [keys]) ⇒ <code>Promise.&lt;boolean&gt;</code>
@@ -1339,6 +1357,7 @@ Automation MetadataType
     * [.retrieveAsTemplate(templateDir, name, templateVariables)](#Automation.retrieveAsTemplate) ⇒ <code>Promise.&lt;TYPE.AutomationItemObj&gt;</code>
     * [.postRetrieveTasks(metadata)](#Automation.postRetrieveTasks) ⇒ <code>TYPE.AutomationItem</code> \| <code>void</code>
     * [.execute(keyArr)](#Automation.execute) ⇒ <code>Promise.&lt;boolean&gt;</code>
+    * [.getErrorsREST(ex)](#Automation.getErrorsREST) ⇒ <code>Array.&lt;string&gt;</code> \| <code>void</code>
     * [.pause(keyArr)](#Automation.pause) ⇒ <code>Promise.&lt;boolean&gt;</code>
     * [.deploy(metadata, targetBU, retrieveDir)](#Automation.deploy) ⇒ <code>Promise.&lt;TYPE.AutomationMap&gt;</code>
     * [.create(metadata)](#Automation.create) ⇒ <code>Promise</code>
@@ -1422,6 +1441,18 @@ a function to start query execution via API
 | --- | --- | --- |
 | keyArr | <code>Array.&lt;string&gt;</code> | customerkey of the metadata |
 
+<a name="Automation.getErrorsREST"></a>
+
+### Automation.getErrorsREST(ex) ⇒ <code>Array.&lt;string&gt;</code> \| <code>void</code>
+Standardizes a check for multiple messages but adds query specific filters to error texts
+
+**Kind**: static method of [<code>Automation</code>](#Automation)  
+**Returns**: <code>Array.&lt;string&gt;</code> \| <code>void</code> - formatted Error Message  
+
+| Param | Type | Description |
+| --- | --- | --- |
+| ex | <code>object</code> | response payload from REST API |
+
 <a name="Automation.pause"></a>
 
 ### Automation.pause(keyArr) ⇒ <code>Promise.&lt;boolean&gt;</code>
@@ -3311,6 +3342,7 @@ Provides default functionality that can be overwritten by child metadata type cl
     * [.retrieveSOAP(retrieveDir, [requestParams], [singleRetrieve], [additionalFields])](#MetadataType.retrieveSOAP) ⇒ <code>Promise.&lt;TYPE.MetadataTypeMapObj&gt;</code>
     * [.retrieveREST(retrieveDir, uri, [templateVariables], [singleRetrieve])](#MetadataType.retrieveREST) ⇒ <code>Promise.&lt;{metadata: (TYPE.MetadataTypeMap\|TYPE.MetadataTypeItem), type: string}&gt;</code>
     * [.executeREST(uri, key)](#MetadataType.executeREST) ⇒ <code>Promise.&lt;string&gt;</code>
+    * [.executeSOAP([metadataEntry])](#MetadataType.executeSOAP) ⇒ <code>Promise.&lt;object&gt;</code>
     * [.runDocumentOnRetrieve([singleRetrieve], metadataMap)](#MetadataType.runDocumentOnRetrieve) ⇒ <code>Promise.&lt;void&gt;</code>
     * [.parseResponseBody(body, [singleRetrieve])](#MetadataType.parseResponseBody) ⇒ <code>TYPE.MetadataTypeMap</code>
     * [.deleteFieldByDefinition(metadataEntry, fieldPath, definitionProperty, origin)](#MetadataType.deleteFieldByDefinition) ⇒ <code>void</code>
@@ -3328,7 +3360,7 @@ Provides default functionality that can be overwritten by child metadata type cl
     * [.findSubType(templateDir, templateName)](#MetadataType.findSubType) ⇒ <code>Promise.&lt;string&gt;</code>
     * [.readSecondaryFolder(templateDir, typeDirArr, templateName, fileName, ex)](#MetadataType.readSecondaryFolder) ⇒ <code>object</code>
     * [.buildDefinition(templateDir, targetDir, templateName, variables)](#MetadataType.buildDefinition) ⇒ <code>Promise.&lt;TYPE.MetadataTypeMapObj&gt;</code>
-    * [.checkForErrors(ex)](#MetadataType.checkForErrors) ⇒ <code>Array.&lt;string&gt;</code> \| <code>void</code>
+    * [.getErrorsREST(ex)](#MetadataType.getErrorsREST) ⇒ <code>Array.&lt;string&gt;</code> \| <code>void</code>
     * [.document([metadata], [isDeploy])](#MetadataType.document) ⇒ <code>void</code>
     * [.deleteByKey(customerKey)](#MetadataType.deleteByKey) ⇒ <code>boolean</code>
     * [.postDeleteTasks(customerKey, [additionalExtensions])](#MetadataType.postDeleteTasks) ⇒ <code>Promise.&lt;void&gt;</code>
@@ -3799,6 +3831,18 @@ Used to execute a query/automation etc.
 | uri | <code>string</code> | REST endpoint where the POST request should be sent |
 | key | <code>string</code> | item key |
 
+<a name="MetadataType.executeSOAP"></a>
+
+### MetadataType.executeSOAP([metadataEntry]) ⇒ <code>Promise.&lt;object&gt;</code>
+Used to execute a query/automation etc.
+
+**Kind**: static method of [<code>MetadataType</code>](#MetadataType)  
+**Returns**: <code>Promise.&lt;object&gt;</code> - api response  
+
+| Param | Type | Description |
+| --- | --- | --- |
+| [metadataEntry] | <code>TYPE.MetadataTypeItem</code> | single metadata entry |
+
 <a name="MetadataType.runDocumentOnRetrieve"></a>
 
 ### MetadataType.runDocumentOnRetrieve([singleRetrieve], metadataMap) ⇒ <code>Promise.&lt;void&gt;</code>
@@ -4037,9 +4081,9 @@ parsing is required (for example scripts & queries)
 | templateName | <code>string</code> | name of the metadata file |
 | variables | <code>TYPE.TemplateMap</code> | variables to be replaced in the metadata |
 
-<a name="MetadataType.checkForErrors"></a>
+<a name="MetadataType.getErrorsREST"></a>
 
-### MetadataType.checkForErrors(ex) ⇒ <code>Array.&lt;string&gt;</code> \| <code>void</code>
+### MetadataType.getErrorsREST(ex) ⇒ <code>Array.&lt;string&gt;</code> \| <code>void</code>
 Standardizes a check for multiple messages
 
 **Kind**: static method of [<code>MetadataType</code>](#MetadataType)  
@@ -4725,7 +4769,7 @@ Query MetadataType
     * [.buildDefinitionForNested(templateDir, targetDir, metadata, templateVariables, templateName)](#Query.buildDefinitionForNested) ⇒ <code>Promise.&lt;Array.&lt;Array.&lt;string&gt;&gt;&gt;</code>
     * [.buildTemplateForNested(templateDir, targetDir, metadata, templateVariables, templateName)](#Query.buildTemplateForNested) ⇒ <code>Promise.&lt;Array.&lt;Array.&lt;string&gt;&gt;&gt;</code>
     * [.getFilesToCommit(keyArr)](#Query.getFilesToCommit) ⇒ <code>Array.&lt;string&gt;</code>
-    * [.checkForErrors(ex)](#Query.checkForErrors) ⇒ <code>Array.&lt;string&gt;</code> \| <code>void</code>
+    * [.getErrorsREST(ex)](#Query.getErrorsREST) ⇒ <code>Array.&lt;string&gt;</code> \| <code>void</code>
     * [.deleteByKey(customerKey)](#Query.deleteByKey) ⇒ <code>boolean</code>
     * [.postDeleteTasks(customerKey)](#Query.postDeleteTasks) ⇒ <code>void</code>
     * [.postDeployTasks(upsertResults)](#Query.postDeployTasks)
@@ -4892,9 +4936,9 @@ additionally, the documentation for dataExtension and automation should be retur
 | --- | --- | --- |
 | keyArr | <code>Array.&lt;string&gt;</code> | customerkey of the metadata |
 
-<a name="Query.checkForErrors"></a>
+<a name="Query.getErrorsREST"></a>
 
-### Query.checkForErrors(ex) ⇒ <code>Array.&lt;string&gt;</code> \| <code>void</code>
+### Query.getErrorsREST(ex) ⇒ <code>Array.&lt;string&gt;</code> \| <code>void</code>
 Standardizes a check for multiple messages but adds query specific filters to error texts
 
 **Kind**: static method of [<code>Query</code>](#Query)  
@@ -5995,6 +6039,7 @@ CLI entry for SFMC DevTools
     * [.getSsjs(code)](#Util.getSsjs) ⇒ <code>string</code>
     * [.stringLike(testString, search)](#Util.stringLike) ⇒ <code>boolean</code>
     * [.fieldsLike(metadata, [filters])](#Util.fieldsLike) ⇒ <code>boolean</code>
+    * [.capitalizeFirstLetter(str)](#Util.capitalizeFirstLetter) ⇒ <code>string</code>
 
 <a name="Util.skipInteraction"></a>
 
@@ -6377,6 +6422,18 @@ returns true if no LIKE filter is defined or if all filters match
 | metadata | <code>TYPE.MetadataTypeItem</code> | a single metadata item |
 | [filters] | <code>object</code> | only used in recursive calls |
 
+<a name="Util.capitalizeFirstLetter"></a>
+
+### Util.capitalizeFirstLetter(str) ⇒ <code>string</code>
+helper used by SOAP methods to ensure the type always uses an upper-cased first letter
+
+**Kind**: static method of [<code>Util</code>](#Util)  
+**Returns**: <code>string</code> - str with first letter capitalized  
+
+| Param | Type | Description |
+| --- | --- | --- |
+| str | <code>string</code> | string to capitalize |
+
 <a name="MetadataTypeDefinitions"></a>
 
 ## MetadataTypeDefinitions
@@ -7913,6 +7970,7 @@ Util that contains logger and simple util methods
     * [.getSsjs(code)](#Util.getSsjs) ⇒ <code>string</code>
     * [.stringLike(testString, search)](#Util.stringLike) ⇒ <code>boolean</code>
     * [.fieldsLike(metadata, [filters])](#Util.fieldsLike) ⇒ <code>boolean</code>
+    * [.capitalizeFirstLetter(str)](#Util.capitalizeFirstLetter) ⇒ <code>string</code>
 
 <a name="Util.skipInteraction"></a>
 
@@ -8295,6 +8353,18 @@ returns true if no LIKE filter is defined or if all filters match
 | metadata | <code>TYPE.MetadataTypeItem</code> | a single metadata item |
 | [filters] | <code>object</code> | only used in recursive calls |
 
+<a name="Util.capitalizeFirstLetter"></a>
+
+### Util.capitalizeFirstLetter(str) ⇒ <code>string</code>
+helper used by SOAP methods to ensure the type always uses an upper-cased first letter
+
+**Kind**: static method of [<code>Util</code>](#Util)  
+**Returns**: <code>string</code> - str with first letter capitalized  
+
+| Param | Type | Description |
+| --- | --- | --- |
+| str | <code>string</code> | string to capitalize |
+
 <a name="csvToArray"></a>
 
 ## csvToArray(csv) ⇒ <code>Array.&lt;string&gt;</code>
@@ -8378,6 +8448,18 @@ helper for [execute](#Automation.execute)
 
 <a name="Automation."></a>
 
+## Automation.(metadataEntry) ⇒ <code>Promise.&lt;object&gt;</code>
+helper for [execute](#Automation.execute)
+
+**Kind**: global function  
+**Returns**: <code>Promise.&lt;object&gt;</code> - Returns the result of the API call  
+
+| Param | Type | Description |
+| --- | --- | --- |
+| metadataEntry | <code>TYPE.AutomationItem</code> | metadata object |
+
+<a name="Automation."></a>
+
 ## Automation.(metadata) ⇒ <code>Promise.&lt;object&gt;</code>
 helper for [pause](#Automation.pause)
 
diff --git a/lib/cli.js b/lib/cli.js
index 36476bad..99d83894 100644
--- a/lib/cli.js
+++ b/lib/cli.js
@@ -89,7 +89,14 @@ yargs
                 .option('execute', {
                     type: 'boolean',
                     group: 'Options for deploy:',
-                    describe: 'optional for query: runs execute after deploy',
+                    describe:
+                        'optional: executes item after deploy; this will run the item once immediately',
+                })
+                .option('schedule', {
+                    type: 'boolean',
+                    group: 'Options for deploy:',
+                    describe:
+                        'optionally start existing schedule instead of running item once immediately (only works for automations)',
                 });
         },
         handler: (argv) => {
@@ -430,6 +437,12 @@ yargs
                     group: 'Options for execute:',
                     describe:
                         'filter metadata components (can include % as wildcard or _ for a single character)',
+                })
+                .option('schedule', {
+                    type: 'boolean',
+                    group: 'Options for execute:',
+                    describe:
+                        'optionally start existing schedule instead of running item once immediately (only works for automations)',
                 });
         },
         handler: (argv) => {
@@ -438,6 +451,37 @@ yargs
             Mcdev.execute(argv.BU, argv.TYPE, csvToArray(argv.KEY));
         },
     })
+    .command({
+        command: 'schedule <BU> <TYPE> [KEY]',
+        aliases: ['sched'],
+        desc: 'starts the predefined schedule of the item (shortcut for running execute --schedule)',
+        builder: (yargs) => {
+            yargs
+                .positional('BU', {
+                    type: 'string',
+                    describe: 'the business unit where to start an item',
+                })
+                .positional('TYPE', {
+                    type: 'string',
+                    describe: 'metadata type',
+                })
+                .positional('KEY', {
+                    type: 'string',
+                    describe: 'key(s) of the metadata component(s)',
+                })
+                .option('like', {
+                    type: 'string',
+                    group: 'Options for execute:',
+                    describe:
+                        'filter metadata components (can include % as wildcard or _ for a single character)',
+                });
+        },
+        handler: (argv) => {
+            Mcdev.setOptions(argv);
+            // ! do not allow multiple types to be passed in here via csvToArray
+            Mcdev.schedule(argv.BU, argv.TYPE, csvToArray(argv.KEY));
+        },
+    })
     .command({
         command: 'pause <BU> <TYPE> [KEY]',
         aliases: ['p', 'stop'],
diff --git a/lib/index.js b/lib/index.js
index 4c836daf..19b625ec 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -54,13 +54,14 @@ class Mcdev {
             'changeKeyField',
             'changeKeyValue',
             'commitHistory',
+            'execute',
             'filter',
             'fromRetrieve',
             'json',
             'like',
             'noLogFile',
             'refresh',
-            'execute',
+            'schedule',
             'skipInteraction',
         ];
         for (const option of knownOptions) {
@@ -728,6 +729,18 @@ class Mcdev {
             return DevOps.getFilesToCommit(properties, buObject, selectedType, keyArr);
         }
     }
+    /**
+     * Schedule an item (shortcut for execute --schedule)
+     *
+     * @param {string} businessUnit name of BU
+     * @param {TYPE.SupportedMetadataTypes} [selectedType] limit to given metadata types
+     * @param {string[]} [keys] customerkey of the metadata
+     * @returns {Promise.<boolean>} true if all started successfully, false if not
+     */
+    static async schedule(businessUnit, selectedType, keys) {
+        this.setOptions({ schedule: true });
+        return this.#runMethod('execute', businessUnit, selectedType, keys);
+    }
     /**
      * Start/execute an item
      *
diff --git a/lib/metadataTypes/Automation.js b/lib/metadataTypes/Automation.js
index 1f6dd6d1..f4855479 100644
--- a/lib/metadataTypes/Automation.js
+++ b/lib/metadataTypes/Automation.js
@@ -242,6 +242,7 @@ class Automation extends MetadataType {
                     key: m.CustomerKey,
                     name: m.Name,
                     programId: automationsLegacy.metadata[m.CustomerKey]?.id,
+                    status: automationsLegacy.metadata[m.CustomerKey]?.status,
                 };
             }
         }
@@ -463,7 +464,8 @@ class Automation extends MetadataType {
     static async execute(keyArr) {
         const metadataMap = {};
         for (const key of keyArr) {
-            if (key) {
+            if (Util.OPTIONS.schedule) {
+                // schedule
                 const results = await this.retrieve(undefined, undefined, undefined, key);
                 if (Object.keys(results.metadata).length) {
                     for (const key of Object.keys(results.metadata)) {
@@ -476,20 +478,34 @@ class Automation extends MetadataType {
                         }
                     }
                 }
+            } else {
+                // runOnce
+                const objectId = await this.#getObjectIdForSingleRetrieve(key);
+                metadataMap[key] = {};
+                metadataMap[key][this.definition.idField] = objectId;
+                metadataMap[key][this.definition.keyField] = key;
             }
         }
-
+        if (!Object.keys(metadataMap).length) {
+            Util.logger.error(`No ${this.definition.type} to execute`);
+            return false;
+        }
         Util.logger.info(
-            `Starting automations according to schedule: ${Object.keys(metadataMap).length}`
+            `Starting automations ${
+                Util.OPTIONS.schedule
+                    ? 'according to schedule'
+                    : 'to run once (use --schedule or --execute=schedule to schedule instead)'
+            }: ${Object.keys(metadataMap).length}`
         );
         const promiseResults = [];
-
         for (const key of Object.keys(metadataMap)) {
-            if (metadataMap[key].status === 'Scheduled') {
+            if (Util.OPTIONS.schedule && metadataMap[key].status === 'Scheduled') {
+                // schedule
                 Util.logger.info(
                     ` - skipping ${this.definition.type} ${metadataMap[key].name}: already scheduled.`
                 );
             } else {
+                // schedule + runOnce
                 promiseResults.push(this.#executeItem(metadataMap, key));
             }
         }
@@ -508,10 +524,43 @@ class Automation extends MetadataType {
      * @returns {Promise.<object>} Returns the result of the API call
      */
     static async #executeItem(metadataMap, key) {
-        this.#preDeploySchedule(metadataMap[key]);
-        metadataMap[key].status = 'Scheduled';
-        return this.#scheduleAutomation(metadataMap, metadataMap, key);
+        if (Util.OPTIONS.schedule) {
+            this.#preDeploySchedule(metadataMap[key]);
+            metadataMap[key].status = 'Scheduled';
+            return this.#scheduleAutomation(metadataMap, metadataMap, key);
+        } else {
+            return this.#runOnce(metadataMap[key]);
+        }
     }
+
+    /**
+     * helper for {@link Automation.execute}
+     *
+     * @param {TYPE.AutomationItem} metadataEntry metadata object
+     * @returns {Promise.<object>} Returns the result of the API call
+     */
+    static async #runOnce(metadataEntry) {
+        return super.executeSOAP(metadataEntry);
+    }
+
+    /**
+     *  Standardizes a check for multiple messages but adds query specific filters to error texts
+     *
+     * @param {object} ex response payload from REST API
+     * @returns {string[] | void} formatted Error Message
+     */
+    static getErrorsREST(ex) {
+        const errors = super.getErrorsREST(ex);
+        if (errors?.length > 0) {
+            return errors.map((msg) =>
+                msg
+                    .split('403 Forbidden')
+                    .join('403 Forbidden: Please check if the automation is currently running.')
+            );
+        }
+        return errors;
+    }
+
     /**
      * a function to start query execution via API
      *
@@ -637,7 +686,16 @@ class Automation extends MetadataType {
      * @returns {Promise} Promise
      */
     static update(metadata, metadataBefore) {
-        metadata.id = metadataBefore.id;
+        if (metadataBefore.status === 'Running') {
+            Util.logger.error(
+                ` ☇ error updating ${this.definition.type} ${
+                    metadata[this.definition.keyField] || metadata[this.definition.nameField]
+                } / ${
+                    metadata[this.definition.nameField]
+                }: You cannot update an automation that's currently running. Please wait a bit and retry.`
+            );
+            return null;
+        }
         const uri = '/automation/v1/automations/' + metadata.id;
         return super.updateREST(metadata, uri);
     }
@@ -844,7 +902,7 @@ class Automation extends MetadataType {
                 }
             }
         }
-        if (Util.OPTIONS.execute) {
+        if (Util.OPTIONS.execute || Util.OPTIONS.schedule) {
             Util.logger.info(`Executing: ${this.definition.type}`);
             await this.execute(Object.keys(metadataMap));
         }
@@ -1427,7 +1485,7 @@ class Automation extends MetadataType {
      * @param {string} key customer key
      * @returns {Promise.<string>} objectId or enpty string
      */
-    static async _getObjectIdForSingleRetrieve(key) {
+    static async #getObjectIdForSingleRetrieve(key) {
         const response = await this.client.soap.retrieve('Program', ['ObjectID'], {
             filter: {
                 leftOperand: 'CustomerKey',
@@ -1446,7 +1504,7 @@ class Automation extends MetadataType {
      */
     static async deleteByKey(customerKey) {
         // the delete endpoint returns a general exception if the automation does not exist; handle it gracefully instead by adding a retrieve first
-        const objectId = customerKey ? await this._getObjectIdForSingleRetrieve(customerKey) : null;
+        const objectId = customerKey ? await this.#getObjectIdForSingleRetrieve(customerKey) : null;
         if (!objectId) {
             Util.logger.error(` - automation not found`);
             return false;
diff --git a/lib/metadataTypes/MetadataType.js b/lib/metadataTypes/MetadataType.js
index d18f7584..9de1a386 100644
--- a/lib/metadataTypes/MetadataType.js
+++ b/lib/metadataTypes/MetadataType.js
@@ -821,7 +821,7 @@ class MetadataType {
             );
             return response;
         } catch (ex) {
-            const parsedErrors = this.checkForErrors(ex);
+            const parsedErrors = this.getErrorsREST(ex);
             Util.logger.error(
                 ` ☇ error creating ${this.definition.type} ${
                     metadataEntry[this.definition.keyField] ||
@@ -851,7 +851,7 @@ class MetadataType {
         this.removeNotCreateableFields(metadataEntry);
         try {
             const response = await this.client.soap.create(
-                soapType.charAt(0).toUpperCase() + soapType.slice(1),
+                Util.capitalizeFirstLetter(soapType),
                 metadataEntry,
                 null
             );
@@ -884,7 +884,7 @@ class MetadataType {
             // set to empty object in case API returned nothing to be able to update it in helper classes
             const response = (await this.client.rest[httpMethod](uri, metadataEntry)) || {};
             await this._postChangeKeyTasks(metadataEntry);
-            this.checkForErrors(response);
+            this.getErrorsREST(response);
             await this.postUpdateTasks(metadataEntry, response);
             // some times, e.g. automation dont return a key in their update response and hence we need to fall back to name
             Util.logger.info(
@@ -895,7 +895,7 @@ class MetadataType {
             );
             return response;
         } catch (ex) {
-            const parsedErrors = this.checkForErrors(ex);
+            const parsedErrors = this.getErrorsREST(ex);
             Util.logger.error(
                 ` ☇ error updating ${this.definition.type} ${
                     metadataEntry[this.definition.keyField] ||
@@ -956,7 +956,7 @@ class MetadataType {
         this.removeNotUpdateableFields(metadataEntry);
         try {
             const response = await this.client.soap.update(
-                soapType.charAt(0).toUpperCase() + soapType.slice(1),
+                Util.capitalizeFirstLetter(soapType),
                 metadataEntry,
                 null
             );
@@ -997,9 +997,14 @@ class MetadataType {
      * @returns {string} error message
      */
     static getSOAPErrorMsg(ex) {
-        return ex?.json?.Results?.length
-            ? `${ex.json.Results[0].StatusMessage} (Code ${ex.json.Results[0].ErrorCode})`
-            : ex.message;
+        if (ex?.json?.Results?.length) {
+            if (ex?.json?.Results[0].StatusMessage) {
+                return `${ex.json.Results[0].StatusMessage} (Code ${ex.json.Results[0].ErrorCode})`;
+            } else if (ex?.json?.Results[0].Result.StatusMessage) {
+                return `${ex.json.Results[0].Result.StatusMessage} (Code ${ex.json.Results[0].Result.ErrorCode})`;
+            }
+        }
+        return ex.message;
     }
     /**
      * Retrieves SOAP via generic fuel-soap wrapper based metadata of metadata type into local filesystem. executes callback with retrieved metadata
@@ -1016,7 +1021,11 @@ class MetadataType {
         const soapType = this.definition.soapType || this.definition.type;
         let response;
         try {
-            response = await this.client.soap.retrieveBulk(soapType, fields, requestParams);
+            response = await this.client.soap.retrieveBulk(
+                Util.capitalizeFirstLetter(soapType),
+                fields,
+                requestParams
+            );
         } catch (ex) {
             this._handleSOAPErrors(ex, 'retrieving');
             return {};
@@ -1100,6 +1109,38 @@ class MetadataType {
         }
     }
 
+    /**
+     * Used to execute a query/automation etc.
+     *
+     * @param {TYPE.MetadataTypeItem} [metadataEntry] single metadata entry
+     * @returns {Promise.<object>} api response
+     */
+    static async executeSOAP(metadataEntry) {
+        const soapType = this.definition.soapType || this.definition.type;
+        try {
+            const response = await this.client.soap.perform(
+                Util.capitalizeFirstLetter(soapType),
+                'start',
+                {
+                    ObjectID: metadataEntry[this.definition.idField],
+                }
+            );
+            if (response?.OverallStatus === 'OK') {
+                Util.logger.info(
+                    ` - executed ${this.definition.type}: ${
+                        metadataEntry[this.definition.keyField]
+                    }`
+                );
+            } else {
+                throw new Error(response?.OverallStatus);
+            }
+            return response;
+        } catch (ex) {
+            this._handleSOAPErrors(ex, 'executing', metadataEntry);
+            return null;
+        }
+    }
+
     /**
      * helper for {@link MetadataType.retrieveREST} and {@link MetadataType.retrieveSOAP}
      *
@@ -1822,7 +1863,7 @@ class MetadataType {
      * @param {object} ex response payload from REST API
      * @returns {string[] | void} formatted Error Message
      */
-    static checkForErrors(ex) {
+    static getErrorsREST(ex) {
         const errors = [];
         if (ex?.response?.status >= 400 && ex?.response?.status < 600) {
             if (ex.response.data.errors) {
@@ -1845,11 +1886,13 @@ class MetadataType {
                 }
             } else if (ex.response.data.message) {
                 errors.push(ex.response.data.message);
-            } else {
+            } else if (ex.response.data) {
                 errors.push(`Undefined Errors: ${JSON.stringify(ex.response.data)}`);
+                Util.logger.debug(JSON.stringify(ex.response.data));
+            } else {
+                errors.push(`${ex.response.status} ${ex.response.statusText}`);
             }
             Util.logger.debug(JSON.stringify(ex.config));
-            Util.logger.debug(JSON.stringify(ex.response.data));
         }
         return errors;
     }
@@ -1912,11 +1955,7 @@ class MetadataType {
         metadata[overrideKeyField || this.definition.keyField] = customerKey;
         const soapType = this.definition.soapType || this.definition.type;
         try {
-            await this.client.soap.delete(
-                soapType.charAt(0).toUpperCase() + soapType.slice(1),
-                metadata,
-                null
-            );
+            await this.client.soap.delete(Util.capitalizeFirstLetter(soapType), metadata, null);
             if (!handleOutside) {
                 Util.logger.info(` - deleted ${this.definition.type}: ${customerKey}`);
             }
diff --git a/lib/metadataTypes/Query.js b/lib/metadataTypes/Query.js
index 4fe76d25..63f0e273 100644
--- a/lib/metadataTypes/Query.js
+++ b/lib/metadataTypes/Query.js
@@ -420,11 +420,12 @@ class Query extends MetadataType {
      * @param {object} ex response payload from REST API
      * @returns {string[] | void} formatted Error Message
      */
-    static checkForErrors(ex) {
-        const errors = super.checkForErrors(ex);
+    static getErrorsREST(ex) {
+        const errors = super.getErrorsREST(ex);
         if (errors?.length > 0) {
             return errors.map((msg) => msg.split('Error saving the Query field.').join(''));
         }
+        return errors;
     }
     /**
      * Delete a metadata item from the specified business unit
diff --git a/lib/util/util.js b/lib/util/util.js
index e552fe38..57df86fc 100644
--- a/lib/util/util.js
+++ b/lib/util/util.js
@@ -834,6 +834,15 @@ const Util = {
             return false;
         });
     },
+    /**
+     * helper used by SOAP methods to ensure the type always uses an upper-cased first letter
+     *
+     * @param {string} str string to capitalize
+     * @returns {string} str with first letter capitalized
+     */
+    capitalizeFirstLetter(str) {
+        return str.charAt(0).toUpperCase() + str.slice(1);
+    },
 };
 
 Util.startLogger(false, true);


Test patch:
diff --git a/test/resourceFactory.js b/test/resourceFactory.js
index 766eced4..4893db34 100644
--- a/test/resourceFactory.js
+++ b/test/resourceFactory.js
@@ -149,6 +149,16 @@ exports.handleSOAPRequest = async (config) => {
 
             break;
         }
+        case 'Perform': {
+            responseXML = await this.loadSOAPRecords(
+                config.headers.SOAPAction.toLocaleLowerCase(),
+                fullObj.Envelope.Body.PerformRequestMsg.Definitions.Definition['@_xsi:type'],
+                jObj.Envelope.Header.fueloauth,
+                fullObj.Envelope.Body.PerformRequestMsg.Definitions.Definition.ObjectID
+            );
+
+            break;
+        }
         default: {
             throw new Error(
                 `The SOAP Action ${config.headers.SOAPAction} is not supported by test handler`
diff --git a/test/resources/9999999/automation/perform-08afb0e2-b00a-4c88-ad2e-1f7f8788c560-response.xml b/test/resources/9999999/automation/perform-08afb0e2-b00a-4c88-ad2e-1f7f8788c560-response.xml
new file mode 100644
index 00000000..103df1e5
--- /dev/null
+++ b/test/resources/9999999/automation/perform-08afb0e2-b00a-4c88-ad2e-1f7f8788c560-response.xml
@@ -0,0 +1,42 @@
+<?xml version="1.0" encoding="utf-8"?>
+<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xmlns:xsd="http://www.w3.org/2001/XMLSchema"
+    xmlns:wsa="http://schemas.xmlsoap.org/ws/2004/08/addressing"
+    xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd"
+    xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">
+    <soap:Header>
+        <wsa:Action>DeleteResponse</wsa:Action>
+        <wsa:MessageID>urn:uuid:cc45bb83-15a3-4a82-ba48-47ac4fb13000</wsa:MessageID>
+        <wsa:RelatesTo>urn:uuid:29986848-a0c3-4f3b-bf5a-91151f784449</wsa:RelatesTo>
+        <wsa:To>http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous</wsa:To>
+        <wsse:Security>
+            <wsu:Timestamp wsu:Id="Timestamp-4e8dad58-7a68-4653-8406-a172d1960c8a">
+                <wsu:Created>2023-06-02T13:41:30Z</wsu:Created>
+                <wsu:Expires>2023-06-02T13:46:30Z</wsu:Expires>
+            </wsu:Timestamp>
+        </wsse:Security>
+    </soap:Header>
+    <soap:Body>
+        <PerformResponseMsg xmlns="http://exacttarget.com/wsdl/partnerAPI">
+            <Results>
+                <Result>
+                    <StatusCode>OK</StatusCode>
+                    <StatusMessage>Performed Activity</StatusMessage>
+                    <Object xsi:type="Automation">
+                        <PartnerKey xsi:nil="true" />
+                        <ObjectID>08afb0e2-b00a-4c88-ad2e-1f7f8788c560</ObjectID>
+                        <CustomerKey>testExisting_automation</CustomerKey>
+                        <IsPlatformObject>false</IsPlatformObject>
+                        <Name />
+                        <Description />
+                        <IsActive>true</IsActive>
+                    </Object>
+                </Result>
+            </Results>
+            <OverallStatus>OK</OverallStatus>
+            <OverallStatusMessage />
+            <RequestID>898702fc-a432-4176-8130-5d6dd5ad9ca6</RequestID>
+        </PerformResponseMsg>
+    </soap:Body>
+</soap:Envelope>
\ No newline at end of file
diff --git a/test/type.automation.test.js b/test/type.automation.test.js
index 531b6b80..de2a5c20 100644
--- a/test/type.automation.test.js
+++ b/test/type.automation.test.js
@@ -116,9 +116,9 @@ describe('type: automation', () => {
             );
             return;
         });
-        it('Should update & start an automation with --execute option', async () => {
+        it('Should update & schedule an automation with --execute option', async () => {
             // WHEN
-            handler.setOptions({ execute: true });
+            handler.setOptions({ schedule: true });
             const deployed = await handler.deploy(
                 'testInstance/testBU',
                 ['automation'],
@@ -178,6 +178,68 @@ describe('type: automation', () => {
             );
             return;
         });
+        it('Should update & runOnce an automation with --execute option', async () => {
+            // WHEN
+            handler.setOptions({ execute: true });
+            const deployed = await handler.deploy(
+                'testInstance/testBU',
+                ['automation'],
+                ['testExisting_automation']
+            );
+            // THEN
+            assert.equal(
+                process.exitCode,
+                false,
+                'deploy with --execute should not have thrown an error'
+            );
+
+            // get results from cache
+            const cached = cache.getCache();
+            assert.equal(
+                cached.automation ? Object.keys(cached.automation).length : 0,
+                2,
+                'two cached automation expected'
+            );
+            assert.equal(
+                deployed['testInstance/testBU'].automation
+                    ? Object.keys(deployed['testInstance/testBU'].automation).length
+                    : 0,
+                1,
+                'one deployed automation expected'
+            );
+            assert.equal(
+                deployed['testInstance/testBU'].automation
+                    ? Object.keys(deployed['testInstance/testBU'].automation)[0]
+                    : null,
+                'testExisting_automation',
+                'expected specific automation to have been deployed'
+            );
+
+            // update
+            assert.deepEqual(
+                await testUtils.getActualJson('testExisting_automation', 'automation'),
+                await testUtils.getExpectedJson('9999999', 'automation', 'update'),
+                'returned metadata was not equal expected for update'
+            );
+            // check if MD file was created and equals expectations
+            expect(file(testUtils.getActualDoc('testExisting_automation', 'automation'))).to.equal(
+                file(
+                    testUtils.getExpectedFile(
+                        '9999999',
+                        'automation',
+                        'update-testExisting_automation',
+                        'md'
+                    )
+                )
+            );
+
+            assert.equal(
+                testUtils.getAPIHistoryLength(),
+                16,
+                'Unexpected number of requests made. Run testUtils.logAPIHistoryDebug() to see the requests'
+            );
+            return;
+        });
         it('Should change the key during update via --changeKeyValue');
     });
     describe('Templating ================', () => {
@@ -319,7 +381,56 @@ describe('type: automation', () => {
         });
     });
     describe('Execute ================', () => {
-        it('Should start an automation by key', async () => {
+        it('Should schedule an automation by key', async () => {
+            const execute = await handler.schedule('testInstance/testBU', 'automation', [
+                'testExisting_automation',
+            ]);
+            assert.equal(process.exitCode, false, 'execute should not have thrown an error');
+            assert.equal(execute, true, 'automation was supposed to be executed');
+            return;
+        });
+        it('Should schedule an automation selected via --like', async () => {
+            handler.setOptions({ like: { key: 'testExist%automation' } });
+            const execute = await handler.schedule('testInstance/testBU', 'automation');
+            assert.equal(process.exitCode, false, 'execute should not have thrown an error');
+            assert.equal(execute, true, 'automation was supposed to be executed');
+            return;
+        });
+        it('Should not schedule executing an automation because key and --like was specified', async () => {
+            handler.setOptions({ like: { key: 'testExisting%' } });
+            const execute = await handler.schedule('testInstance/testBU', 'automation', [
+                'testExisting_automation',
+            ]);
+            assert.equal(process.exitCode, true, 'execute should not have thrown an error');
+            assert.equal(execute, false, 'automation was not supposed to be executed');
+            return;
+        });
+        it('Should execute --schedule an automation by key', async () => {
+            handler.setOptions({ schedule: true });
+            const execute = await handler.execute('testInstance/testBU', 'automation', [
+                'testExisting_automation',
+            ]);
+            assert.equal(process.exitCode, false, 'execute should not have thrown an error');
+            assert.equal(execute, true, 'automation was supposed to be executed');
+            return;
+        });
+        it('Should execute --schedule an automation selected via --like', async () => {
+            handler.setOptions({ like: { key: 'testExist%automation' }, schedule: true });
+            const execute = await handler.execute('testInstance/testBU', 'automation');
+            assert.equal(process.exitCode, false, 'execute should not have thrown an error');
+            assert.equal(execute, true, 'automation was supposed to be executed');
+            return;
+        });
+        it('Should not execute --schedule executing an automation because key and --like was specified', async () => {
+            handler.setOptions({ like: { key: 'testExisting%' }, schedule: true });
+            const execute = await handler.execute('testInstance/testBU', 'automation', [
+                'testExisting_automation',
+            ]);
+            assert.equal(process.exitCode, true, 'execute should not have thrown an error');
+            assert.equal(execute, false, 'automation was not supposed to be executed');
+            return;
+        });
+        it('Should runOnce an automation by key', async () => {
             const execute = await handler.execute('testInstance/testBU', 'automation', [
                 'testExisting_automation',
             ]);
@@ -327,14 +438,14 @@ describe('type: automation', () => {
             assert.equal(execute, true, 'automation was supposed to be executed');
             return;
         });
-        it('Should start an automation selected via --like', async () => {
+        it('Should runOnce an automation selected via --like', async () => {
             handler.setOptions({ like: { key: 'testExist%automation' } });
             const execute = await handler.execute('testInstance/testBU', 'automation');
             assert.equal(process.exitCode, false, 'execute should not have thrown an error');
             assert.equal(execute, true, 'automation was supposed to be executed');
             return;
         });
-        it('Should not start executing an automation because key and --like was specified', async () => {
+        it('Should not runOnce executing an automation because key and --like was specified', async () => {
             handler.setOptions({ like: { key: 'testExisting%' } });
             const execute = await handler.execute('testInstance/testBU', 'automation', [
                 'testExisting_automation',
@@ -360,12 +471,12 @@ describe('type: automation', () => {
             assert.equal(pause, true, 'automation was supposed to be paused');
             return;
         });
-        it('Should not pause executing a automation because key and --like was specified', async () => {
+        it('Should not pause automation because key and --like was specified', async () => {
             handler.setOptions({ like: { key: 'testExisting_a%n_pause' } });
             const pause = await handler.pause('testInstance/testBU', 'automation', [
                 'testExisting_automation_pause',
             ]);
-            assert.equal(process.exitCode, true, 'pause should not have thrown an error');
+            assert.equal(process.exitCode, true, 'pause should have thrown an error');
             assert.equal(pause, false, 'automation was not supposed to be paused');
             return;
         });