mcdev 5.1.0 → 5.2.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.
Files changed (192) hide show
  1. package/.eslintrc.json +4 -4
  2. package/.github/ISSUE_TEMPLATE/bug.yml +1 -0
  3. package/.github/PULL_REQUEST_TEMPLATE.md +2 -2
  4. package/.github/workflows/coverage-develop-branch.yml +0 -2
  5. package/.github/workflows/coverage-main-branch.yml +0 -2
  6. package/.github/workflows/coverage.yml +0 -2
  7. package/.husky/post-checkout +1 -0
  8. package/.husky/post-merge +1 -0
  9. package/.vscode/extensions.json +4 -0
  10. package/docs/dist/documentation.md +633 -286
  11. package/lib/Deployer.js +25 -25
  12. package/lib/MetadataTypeDefinitions.js +1 -1
  13. package/lib/MetadataTypeInfo.js +1 -1
  14. package/lib/Retriever.js +1 -1
  15. package/lib/cli.js +159 -9
  16. package/lib/index.js +395 -95
  17. package/lib/metadataTypes/Asset.js +10 -11
  18. package/lib/metadataTypes/AttributeGroup.js +76 -2
  19. package/lib/metadataTypes/AttributeSet.js +260 -0
  20. package/lib/metadataTypes/Automation.js +413 -96
  21. package/lib/metadataTypes/DataExtension.js +2 -2
  22. package/lib/metadataTypes/DataExtensionField.js +1 -1
  23. package/lib/metadataTypes/Event.js +2 -3
  24. package/lib/metadataTypes/Folder.js +1 -1
  25. package/lib/metadataTypes/Journey.js +5 -6
  26. package/lib/metadataTypes/MetadataType.js +149 -49
  27. package/lib/metadataTypes/MobileKeyword.js +8 -8
  28. package/lib/metadataTypes/MobileMessage.js +5 -5
  29. package/lib/metadataTypes/Query.js +26 -10
  30. package/lib/metadataTypes/Script.js +3 -3
  31. package/lib/metadataTypes/TransactionalSMS.js +5 -5
  32. package/lib/metadataTypes/TriggeredSend.js +25 -50
  33. package/lib/metadataTypes/User.js +7 -4
  34. package/lib/metadataTypes/definitions/AttributeGroup.definition.js +117 -106
  35. package/lib/metadataTypes/definitions/{SetDefinition.definition.js → AttributeSet.definition.js} +54 -27
  36. package/lib/metadataTypes/definitions/Automation.definition.js +22 -15
  37. package/lib/metadataTypes/definitions/ImportFile.definition.js +36 -6
  38. package/lib/metadataTypes/definitions/TriggeredSend.definition.js +1 -0
  39. package/lib/util/cache.js +9 -4
  40. package/lib/util/cli.js +40 -0
  41. package/lib/util/file.js +2 -2
  42. package/lib/util/init.js +84 -0
  43. package/lib/util/util.js +121 -13
  44. package/package.json +11 -11
  45. package/test/mockRoot/.mcdevrc.json +1 -1
  46. package/test/mockRoot/deploy/testInstance/testBU/automation/testExisting_automation.automation-meta.json +1 -2
  47. package/test/mockRoot/deploy/testInstance/testBU/automation/testNew_automation.automation-meta.json +5 -6
  48. package/test/mockRoot/deploy/testInstance/testBU/dataExtract/testExisting_dataExtract.dataExtract-meta.json +35 -0
  49. package/test/mockRoot/deploy/testInstance/testBU/dataExtract/testNew_dataExtract.dataExtract-meta.json +35 -0
  50. package/test/mockRoot/deploy/testInstance/testBU/fileTransfer/testExisting_fileTransfer.fileTransfer-meta.json +17 -0
  51. package/test/mockRoot/deploy/testInstance/testBU/fileTransfer/testNew_fileTransfer.fileTransfer-meta.json +17 -0
  52. package/test/mockRoot/deploy/testInstance/testBU/importFile/testExisting_importFile.importFile-meta.json +29 -0
  53. package/test/mockRoot/deploy/testInstance/testBU/importFile/testNew_importFile.importFile-meta.json +29 -0
  54. package/test/mockRoot/deploy/testInstance/testBU/query/testExisting_query_fixKeys.query-meta.json +11 -0
  55. package/test/mockRoot/deploy/testInstance/testBU/query/testExisting_query_fixKeys.query-meta.sql +6 -0
  56. package/test/mockRoot/deploy/testInstance/testBU/script/testExisting_script.script-meta.json +6 -0
  57. package/test/mockRoot/deploy/testInstance/testBU/script/testExisting_script.script-meta.ssjs +1 -0
  58. package/test/mockRoot/deploy/testInstance/testBU/script/testNew_script.script-meta.json +6 -0
  59. package/test/mockRoot/deploy/testInstance/testBU/script/testNew_script.script-meta.ssjs +1 -0
  60. package/test/mockRoot/deploy/testInstance/testBU/triggeredSend/testExisting_triggeredSend.triggeredSend-meta.json +29 -0
  61. package/test/mockRoot/deploy/testInstance/testBU/triggeredSend/testNew_triggeredSend.triggeredSend-meta.json +29 -0
  62. package/test/resourceFactory.js +77 -12
  63. package/test/resources/1111111/accountUser/retrieve-ActiveFlag=falseANDCustomerKey=testExisting_userANDEmaillike@-response.xml +27 -0
  64. package/test/resources/1111111/accountUser/retrieve-ActiveFlag=falseANDEmaillike@-response.xml +156 -0
  65. package/test/resources/1111111/accountUser/retrieve-ActiveFlag=trueANDEmailisNullORNamelikeapp userANDMustChangePassword=false-response.xml +87 -0
  66. package/test/resources/1111111/accountUser/retrieve-ActiveFlag=trueANDEmaillike@-response.xml +156 -0
  67. package/test/resources/1111111/accountUser/retrieve-CustomerKey=testExisting_userANDActiveFlag=trueANDEmailisNullORNamelikeapp userANDMustChangePassword=false-response.xml +27 -0
  68. package/test/resources/1111111/accountUserAccount/retrieve-AccountUser.AccountUserID=700301950-response.xml +60 -0
  69. package/test/resources/1111111/user/retrieve-expected.md +4 -2
  70. package/test/resources/9999999/attributeGroup/retrieve-expected.json +25 -0
  71. package/test/resources/9999999/attributeSet/retrieve-expected.json +748 -0
  72. package/test/resources/9999999/automation/build-expected.json +1 -2
  73. package/test/resources/9999999/automation/create-expected.json +7 -8
  74. package/test/resources/9999999/automation/create-testNew_automation-expected.md +4 -4
  75. package/test/resources/9999999/automation/patch_fixKeys-pause-expected.json +44 -0
  76. package/test/resources/9999999/automation/patch_fixKeys-schedule-expected.json +44 -0
  77. package/test/resources/9999999/automation/perform-08afb0e2-b00a-4c88-ad2e-1f7f8788c560-response.xml +42 -0
  78. package/test/resources/9999999/automation/perform-08afb0e2-b00a-4c88-fixKey_pause-response.xml +42 -0
  79. package/test/resources/9999999/automation/perform-08afb0e2-b00a-4c88-fixKey_schedule-response.xml +42 -0
  80. package/test/resources/9999999/automation/perform-a8afb0e2-b00a-4c88-ad2e-1f7f8788c560-response.xml +42 -0
  81. package/test/resources/9999999/automation/retrieve-expected.json +1 -2
  82. package/test/resources/9999999/automation/retrieve-testExisting_automation-expected.md +2 -2
  83. package/test/resources/9999999/automation/schedule-08afb0e2-b00a-4c88-ad2e-1f7f8788c560-response.xml +52 -0
  84. package/test/resources/9999999/automation/schedule-08afb0e2-b00a-4c88-ad2e-pause-response.xml +38 -0
  85. package/test/resources/9999999/automation/schedule-08afb0e2-b00a-4c88-fixKey_pause-response.xml +52 -0
  86. package/test/resources/9999999/automation/schedule-08afb0e2-b00a-4c88-fixKey_schedule-response.xml +52 -0
  87. package/test/resources/9999999/automation/schedule-a8afb0e2-b00a-4c88-ad2e-1f7f8788c560-response.xml +52 -0
  88. package/test/resources/9999999/automation/template-expected.json +1 -2
  89. package/test/resources/9999999/automation/update-expected.json +1 -2
  90. package/test/resources/9999999/automation/update-testExisting_automation-expected.md +2 -2
  91. package/test/resources/9999999/automation/v1/automations/08afb0e2-b00a-4c88-ad2e-pause/get-response.json +85 -0
  92. package/test/resources/9999999/automation/v1/automations/08afb0e2-b00a-4c88-ad2e-pause/patch-response.json +85 -0
  93. package/test/resources/9999999/automation/v1/automations/08afb0e2-b00a-4c88-fixKey_pause/get-response.json +85 -0
  94. package/test/resources/9999999/automation/v1/automations/08afb0e2-b00a-4c88-fixKey_pause/patch-response.json +85 -0
  95. package/test/resources/9999999/automation/v1/automations/08afb0e2-b00a-4c88-fixKey_schedule/get-response.json +85 -0
  96. package/test/resources/9999999/automation/v1/automations/08afb0e2-b00a-4c88-fixKey_schedule/patch-response.json +85 -0
  97. package/test/resources/9999999/automation/v1/automations/a8afb0e2-b00a-4c88-ad2e-1f7f8788c560/get-response.json +1 -1
  98. package/test/resources/9999999/automation/v1/automations/post-response.json +20 -19
  99. package/test/resources/9999999/automation/v1/dataextracts/56c5370a-f988-4f36-b0ee-0f876573f6d7/patch-response.json +38 -0
  100. package/test/resources/9999999/automation/v1/dataextracts/post-response.json +38 -0
  101. package/test/resources/9999999/automation/v1/dataextracttypes/get-response.json +50 -0
  102. package/test/resources/9999999/automation/v1/filetransfers/72c328ac-f5b0-4e37-91d3-a775666f15a6/patch-response.json +18 -0
  103. package/test/resources/9999999/automation/v1/filetransfers/post-response.json +18 -0
  104. package/test/resources/9999999/automation/v1/ftplocations/get-response.json +18 -0
  105. package/test/resources/9999999/automation/v1/imports/9d16f42c-2260-ed11-b849-48df37d1de8b/patch-response.json +31 -0
  106. package/test/resources/9999999/automation/v1/imports/get-response.json +1 -1
  107. package/test/resources/9999999/automation/v1/imports/post-response.json +30 -0
  108. package/test/resources/9999999/automation/v1/queries/549f0568-607c-4940-afef-437965094dae/actions/start/post-response.txt +1 -0
  109. package/test/resources/9999999/automation/v1/queries/549f0568-607c-4940-afef-437965094dat_fixKeys/get-response.json +17 -0
  110. package/test/resources/9999999/automation/v1/queries/549f0568-607c-4940-afef-437965094dat_fixKeys/patch-response.json +18 -0
  111. package/test/resources/9999999/automation/v1/queries/get-response.json +18 -1
  112. package/test/resources/9999999/automation/v1/scripts/39f6a488-20eb-4ba0-b0b9-023725b574e4/patch-response.json +10 -0
  113. package/test/resources/9999999/automation/v1/scripts/get-response.json +12 -2
  114. package/test/resources/9999999/automation/v1/scripts/post-response.json +10 -0
  115. package/test/resources/9999999/dataExtension/retrieve-Name=testExisting_dataExtension-response.xml +52 -0
  116. package/test/resources/9999999/dataExtensionField/retrieve-DataExtension.CustomerKey=testExisting_dataExtension-response.xml +98 -0
  117. package/test/resources/9999999/dataExtensionField/retrieve-DataExtension.CustomerKey=testNew_dataExtensionORDataExtension.CustomerKey=testExisting_dataExtension-response.xml +99 -0
  118. package/test/resources/9999999/dataExtract/build-expected.json +35 -0
  119. package/test/resources/9999999/dataExtract/get-expected.json +39 -0
  120. package/test/resources/9999999/dataExtract/patch-expected.json +37 -0
  121. package/test/resources/9999999/dataExtract/post-expected.json +37 -0
  122. package/test/resources/9999999/dataExtract/template-expected.json +35 -0
  123. package/test/resources/9999999/dataFolder/retrieve-ContentType=contextual_suppression_listORContentType=publicationORContentType=suppression_listORContentType=mysubsORContentType=list-response.xml +136 -0
  124. package/test/resources/9999999/dataFolder/retrieve-ContentType=ssjsactivity-response.xml +48 -0
  125. package/test/resources/9999999/dataFolder/retrieve-ContentType=triggered_send_journeybuilderORContentType=triggered_sendORContentType=hidden-response.xml +276 -0
  126. package/test/resources/9999999/dataFolder/retrieve-response.xml +23 -0
  127. package/test/resources/9999999/email/retrieve-response.xml +203 -0
  128. package/test/resources/9999999/fileTransfer/build-expected.json +15 -0
  129. package/test/resources/9999999/fileTransfer/get-expected.json +17 -0
  130. package/test/resources/9999999/fileTransfer/patch-expected.json +17 -0
  131. package/test/resources/9999999/fileTransfer/post-expected.json +17 -0
  132. package/test/resources/9999999/fileTransfer/template-expected.json +15 -0
  133. package/test/resources/9999999/hub/v1/contacts/schema/attributeGroups/get-response.json +585 -0
  134. package/test/resources/9999999/hub/v1/contacts/schema/setDefinitions/get-response.json +19807 -0
  135. package/test/resources/9999999/importFile/build-expected.json +27 -0
  136. package/test/resources/9999999/importFile/get-expected.json +29 -0
  137. package/test/resources/9999999/importFile/patch-expected.json +29 -0
  138. package/test/resources/9999999/importFile/post-expected.json +29 -0
  139. package/test/resources/9999999/importFile/template-expected.json +27 -0
  140. package/test/resources/9999999/program/retrieve-CustomerKey=testExisting_automation_fixKey_pause-response.xml +32 -0
  141. package/test/resources/9999999/program/retrieve-CustomerKey=testExisting_automation_fixKey_schedule-response.xml +32 -0
  142. package/test/resources/9999999/program/retrieve-CustomerKey=testExisting_automation_fixedKey_paused-response.xml +32 -0
  143. package/test/resources/9999999/program/retrieve-CustomerKey=testExisting_automation_fixedKey_scheduled-response.xml +32 -0
  144. package/test/resources/9999999/program/retrieve-CustomerKey=testExisting_automation_pause-response.xml +30 -0
  145. package/test/resources/9999999/program/retrieve-response.xml +21 -3
  146. package/test/resources/9999999/query/patch_fixKeys-expected.json +11 -0
  147. package/test/resources/9999999/query/patch_fixKeys-expected.sql +6 -0
  148. package/test/resources/9999999/queryDefinition/retrieve-CustomerKey=testExisting_query_fixKeysANDStatus=Active-response.xml +30 -0
  149. package/test/resources/9999999/queryDefinition/retrieve-CustomerKey=testExisting_query_fixedKeysANDStatus=Active-response.xml +30 -0
  150. package/test/resources/9999999/queryDefinition/retrieve-CustomerKey=testNew_queryANDStatus=Active-response.xml +30 -0
  151. package/test/resources/9999999/script/build-expected.json +6 -0
  152. package/test/resources/9999999/script/build-expected.ssjs +1 -0
  153. package/test/resources/9999999/script/get-expected.json +8 -0
  154. package/test/resources/9999999/script/get-expected.ssjs +1 -0
  155. package/test/resources/9999999/script/get_noScriptTag-expected.html +1 -0
  156. package/test/resources/9999999/script/get_noScriptTag-expected.json +8 -0
  157. package/test/resources/9999999/script/patch-expected.json +8 -0
  158. package/test/resources/9999999/script/patch-expected.ssjs +1 -0
  159. package/test/resources/9999999/script/post-expected.json +8 -0
  160. package/test/resources/9999999/script/post-expected.ssjs +1 -0
  161. package/test/resources/9999999/script/template-expected.json +6 -0
  162. package/test/resources/9999999/script/template-expected.ssjs +1 -0
  163. package/test/resources/9999999/triggeredSend/build-expected.json +29 -0
  164. package/test/resources/9999999/triggeredSend/get-expected.json +29 -0
  165. package/test/resources/9999999/triggeredSend/patch-expected.json +29 -0
  166. package/test/resources/9999999/triggeredSend/post-expected.json +29 -0
  167. package/test/resources/9999999/triggeredSend/template-expected.json +29 -0
  168. package/test/resources/9999999/triggeredSendDefinition/create-response.xml +75 -0
  169. package/test/resources/9999999/triggeredSendDefinition/delete-response.xml +36 -0
  170. package/test/resources/9999999/triggeredSendDefinition/{retrieve-response.xml → retrieve-TriggeredSendStatusINNew,Active,Inactive,Moved,Canceled-response.xml} +4 -4
  171. package/test/resources/9999999/triggeredSendDefinition/update-response.xml +74 -0
  172. package/test/type.attributeGroup.test.js +55 -0
  173. package/test/type.attributeSet.test.js +55 -0
  174. package/test/type.automation.test.js +638 -11
  175. package/test/type.dataExtension.test.js +0 -1
  176. package/test/type.dataExtract.test.js +187 -0
  177. package/test/type.fileTransfer.test.js +185 -0
  178. package/test/type.importFile.test.js +186 -0
  179. package/test/type.mobileKeyword.test.js +0 -1
  180. package/test/type.query.test.js +464 -13
  181. package/test/type.script.test.js +367 -0
  182. package/test/type.triggeredSend.test.js +152 -0
  183. package/test/type.user.test.js +22 -10
  184. package/test/utils.js +4 -1
  185. package/lib/metadataTypes/SetDefinition.js +0 -37
  186. /package/test/resources/1111111/accountUser/{retrieve-response.xml → retrieve-ActiveFlag=trueANDCustomerKey=testExisting_userANDEmaillike@-response.xml} +0 -0
  187. /package/test/resources/1111111/accountUserAccount/{retrieve-response.xml → retrieve-AccountUser.AccountUserIDIN700301950,700301951,7471228-response.xml} +0 -0
  188. /package/test/resources/1111111/businessUnit/{retrieve-response.xml → retrieve-ID=1111111-response.xml} +0 -0
  189. /package/test/resources/1111111/list/{retrieve-response.xml → retrieve-CustomerKey=All SubscribersORListName=All Subscribers-response.xml} +0 -0
  190. /package/test/resources/1111111/role/{retrieve-response.xml → retrieve-IsPrivate=false-response.xml} +0 -0
  191. /package/test/resources/9999999/emailSendDefinition/{retrieve-response.xml → retrieve-IsPlatformObject=falseANDDescriptionnotEqualsSFSendDefinition-response.xml} +0 -0
  192. /package/test/resources/9999999/queryDefinition/{retrieve-response.xml → retrieve-CustomerKey=testExisting_queryANDStatus=Active-response.xml} +0 -0
@@ -47,34 +47,41 @@ class Automation extends MetadataType {
47
47
  )
48
48
  );
49
49
  }
50
+ // the API seems to handle 50 concurrent requests nicely
51
+ const rateLimit = pLimit(50);
52
+
50
53
  const details = results.Results
51
54
  ? await Promise.all(
52
- results.Results.map(async (a) => {
53
- try {
54
- return await this.client.rest.get(
55
- '/automation/v1/automations/' + a.ObjectID
56
- );
57
- } catch (ex) {
55
+ results.Results.map(async (item) =>
56
+ rateLimit(async () => {
58
57
  try {
59
- if (ex.message == 'socket hang up') {
60
- // one more retry; it's a rare case but retrying again should solve the issue gracefully
61
- return await this.client.rest.get(
62
- '/automation/v1/automations/' + a.ObjectID
63
- );
58
+ return await this.client.rest.get(
59
+ '/automation/v1/automations/' + item.ObjectID
60
+ );
61
+ } catch (ex) {
62
+ try {
63
+ if (ex.message == 'socket hang up') {
64
+ // one more retry; it's a rare case but retrying again should solve the issue gracefully
65
+ return await this.client.rest.get(
66
+ '/automation/v1/automations/' + item.ObjectID
67
+ );
68
+ }
69
+ } catch {
70
+ // no extra action needed, handled below
64
71
  }
65
- } catch {
66
- // no extra action needed, handled below
72
+ // if we do get here, we should log the error and continue instead of failing to download all automations
73
+ Util.logger.error(
74
+ ` ☇ skipping Automation ${item.ObjectID}: ${ex.message} ${ex.code}`
75
+ );
76
+ return null;
67
77
  }
68
- // if we do get here, we should log the error and continue instead of failing to download all automations
69
- Util.logger.error(
70
- ` ☇ skipping Automation ${a.ObjectID}: ${ex.message} ${ex.code}`
71
- );
72
- return null;
73
- }
74
- })
78
+ })
79
+ )
75
80
  )
76
81
  : [];
77
- let metadataMap = this.parseResponseBody({ items: details });
82
+
83
+ // * if retrieving some automations fails, a null element would remain in the details-array for each of them that needs to be filtered to prevent it from causing issues elsewhere
84
+ let metadataMap = this.parseResponseBody({ items: details.filter(Boolean) });
78
85
 
79
86
  if (Object.keys(metadataMap).length) {
80
87
  // attach notification information to each automation that has any
@@ -144,11 +151,19 @@ class Automation extends MetadataType {
144
151
  }));
145
152
  found++;
146
153
  } else {
147
- throw new TypeError(JSON.stringify(notificationsResult));
154
+ if (
155
+ !notificationsResult ||
156
+ typeof notificationsResult !== 'object' ||
157
+ Object.keys(notificationsResult).length !== 1 ||
158
+ !notificationsResult?.programId
159
+ ) {
160
+ throw new TypeError(JSON.stringify(notificationsResult));
161
+ }
162
+ // * if there are no automation notifications, the API returns a single object with the programId
148
163
  }
149
164
  } catch (ex) {
150
165
  Util.logger.debug(
151
- ` ☇ skipping Notifications for Automation ${automationLegacy.key}: ${ex.message} ${ex.code}`
166
+ ` ☇ issue retrieving Notifications for automation ${automationLegacy.key}: ${ex.message} ${ex.code}`
152
167
  );
153
168
  skipped++;
154
169
  }
@@ -242,6 +257,7 @@ class Automation extends MetadataType {
242
257
  key: m.CustomerKey,
243
258
  name: m.Name,
244
259
  programId: automationsLegacy.metadata[m.CustomerKey]?.id,
260
+ status: automationsLegacy.metadata[m.CustomerKey]?.status,
245
261
  };
246
262
  }
247
263
  }
@@ -318,6 +334,29 @@ class Automation extends MetadataType {
318
334
  throw new Error(JSON.stringify(results));
319
335
  }
320
336
  }
337
+ /**
338
+ * helper for {@link Automation.postRetrieveTasks} and {@link Automation.execute}
339
+ *
340
+ * @param {TYPE.AutomationItem} metadata a single automation
341
+ * @returns {boolean} true if the automation schedule is valid
342
+ */
343
+ static #isValidSchedule(metadata) {
344
+ if (metadata.type === 'scheduled' && metadata.schedule?.startDate) {
345
+ try {
346
+ if (this.definition.timeZoneMapping[metadata.schedule.timezoneName]) {
347
+ // if we found the id in our list, remove the redundant data
348
+ delete metadata.schedule.timezoneId;
349
+ }
350
+ } catch {
351
+ Util.logger.debug(
352
+ `- Schedule name '${metadata.schedule.timezoneName}' not found in definition.timeZoneMapping`
353
+ );
354
+ }
355
+ return true;
356
+ } else {
357
+ return false;
358
+ }
359
+ }
321
360
  /**
322
361
  * manages post retrieve steps
323
362
  *
@@ -332,34 +371,24 @@ class Automation extends MetadataType {
332
371
  if (metadata.type === 'scheduled' && metadata.schedule?.startDate) {
333
372
  // Starting Source == 'Schedule'
334
373
 
335
- try {
336
- if (this.definition.timeZoneMapping[metadata.schedule.timezoneName]) {
337
- // if we found the id in our list, remove the redundant data
338
- delete metadata.schedule.timezoneId;
339
- }
340
- } catch {
341
- Util.logger.debug(
342
- `- Schedule name '${metadata.schedule.timezoneName}' not found in definition.timeZoneMapping`
343
- );
344
- }
345
- try {
346
- // type 'Running' is temporary status only, overwrite with Scheduled for storage.
347
- if (metadata.type === 'scheduled' && metadata.status === 'Running') {
348
- metadata.status = 'Scheduled';
349
- }
350
- } catch {
351
- Util.logger.error(
352
- `- ${this.definition.type} ${metadata.name} does not have a valid schedule setting.`
353
- );
374
+ if (!this.#isValidSchedule(metadata)) {
354
375
  return;
355
376
  }
377
+ // type 'Running' is temporary status only, overwrite with Scheduled for storage.
378
+ if (metadata.type === 'scheduled' && metadata.status === 'Running') {
379
+ metadata.status = 'Scheduled';
380
+ }
356
381
  } else if (metadata.type === 'triggered' && metadata.fileTrigger) {
357
382
  // Starting Source == 'File Drop'
358
383
  // Do nothing for now
359
384
  }
360
385
  if (metadata.steps) {
386
+ let i = 0;
387
+
361
388
  for (const step of metadata.steps) {
362
- const stepNumber = step.stepNumber || step.step;
389
+ i++;
390
+
391
+ const stepNumber = step.stepNumber || step.step || i;
363
392
  delete step.stepNumber;
364
393
  delete step.step;
365
394
 
@@ -387,14 +416,22 @@ class Automation extends MetadataType {
387
416
  activity.activityObjectId == null
388
417
  ) {
389
418
  Util.logger.debug(
390
- ` - skip parsing of activity due to missing activityObjectId: ${JSON.stringify(
391
- activity
392
- )}`
419
+ ` - skipping ${
420
+ metadata[this.definition.keyField]
421
+ } activity ${stepNumber}.${
422
+ activity.displayOrder
423
+ } due to missing activityObjectId: ${JSON.stringify(activity)}`
393
424
  );
394
425
  // empty if block
395
426
  } else if (!this.definition.dependencies.includes(activity.r__type)) {
396
427
  Util.logger.debug(
397
- ` - skip parsing because the type is not set up as a dependecy for ${this.definition.type}`
428
+ ` - skipping ${
429
+ metadata[this.definition.keyField]
430
+ } activity ${stepNumber}.${
431
+ activity.displayOrder
432
+ } because the type ${
433
+ activity.r__type
434
+ } is not set up as a dependency for ${this.definition.type}`
398
435
  );
399
436
  }
400
437
  // / if managed by cache we can update references to support deployment
@@ -445,6 +482,197 @@ class Automation extends MetadataType {
445
482
  return null;
446
483
  }
447
484
  }
485
+ /**
486
+ * a function to start query execution via API
487
+ *
488
+ * @param {string[]} keyArr customerkey of the metadata
489
+ * @returns {Promise.<string[]>} Returns list of keys that were executed
490
+ */
491
+ static async execute(keyArr) {
492
+ const metadataMap = {};
493
+ for (const key of keyArr) {
494
+ if (Util.OPTIONS.schedule) {
495
+ // schedule
496
+ const results = await this.retrieve(undefined, undefined, undefined, key);
497
+ if (Object.keys(results.metadata).length) {
498
+ for (const resultKey of Object.keys(results.metadata)) {
499
+ if (this.#isValidSchedule(results.metadata[resultKey])) {
500
+ metadataMap[resultKey] = results.metadata[resultKey];
501
+ } else {
502
+ Util.logger.error(
503
+ ` - skipping ${this.definition.type} ${results.metadata[resultKey].name}: no valid schedule settings found.`
504
+ );
505
+ }
506
+ }
507
+ }
508
+ } else {
509
+ // runOnce
510
+ const objectId = await this.#getObjectIdForSingleRetrieve(key);
511
+ metadataMap[key] = {};
512
+ metadataMap[key][this.definition.idField] = objectId;
513
+ metadataMap[key][this.definition.keyField] = key;
514
+ }
515
+ }
516
+ if (!Object.keys(metadataMap).length) {
517
+ Util.logger.error(`No ${this.definition.type} to execute`);
518
+ return false;
519
+ }
520
+ Util.logger.info(
521
+ `Starting automations ${
522
+ Util.OPTIONS.schedule
523
+ ? 'according to schedule'
524
+ : 'to run once (use --schedule or --execute=schedule to schedule instead)'
525
+ }: ${Object.keys(metadataMap).length}`
526
+ );
527
+ const promiseResults = [];
528
+ for (const key of Object.keys(metadataMap)) {
529
+ if (Util.OPTIONS.schedule && metadataMap[key].status === 'Scheduled') {
530
+ // schedule
531
+ Util.logger.info(
532
+ ` - skipping ${this.definition.type} ${metadataMap[key].name}: already scheduled.`
533
+ );
534
+ } else {
535
+ // schedule + runOnce
536
+ promiseResults.push(this.#executeItem(metadataMap, key));
537
+ }
538
+ }
539
+ const results = await Promise.all(promiseResults);
540
+ const executedKeyArr = results
541
+ .filter(Boolean)
542
+ .filter((r) => r.response.OverallStatus === 'OK')
543
+ .map((r) => r.key);
544
+ Util.logger.info(`Executed ${executedKeyArr.length} of ${keyArr.length} items`);
545
+ return executedKeyArr;
546
+ }
547
+ /**
548
+ * helper for {@link Automation.execute}
549
+ *
550
+ * @param {TYPE.AutomationMap} metadataMap map of metadata
551
+ * @param {string} key key of the metadata
552
+ * @returns {Promise.<{key:string, response:object}>} metadata key and API response
553
+ */
554
+ static async #executeItem(metadataMap, key) {
555
+ if (Util.OPTIONS.schedule) {
556
+ this.#preDeploySchedule(metadataMap[key]);
557
+ metadataMap[key].status = 'Scheduled';
558
+ return this.#scheduleAutomation(metadataMap, metadataMap, key);
559
+ } else {
560
+ return this.#runOnce(metadataMap[key]);
561
+ }
562
+ }
563
+
564
+ /**
565
+ * helper for {@link Automation.execute}
566
+ *
567
+ * @param {TYPE.AutomationItem} metadataEntry metadata object
568
+ * @returns {Promise.<{key:string, response:object}>} metadata key and API response
569
+ */
570
+ static async #runOnce(metadataEntry) {
571
+ return super.executeSOAP(metadataEntry);
572
+ }
573
+
574
+ /**
575
+ * Standardizes a check for multiple messages but adds query specific filters to error texts
576
+ *
577
+ * @param {object} ex response payload from REST API
578
+ * @returns {string[] | void} formatted Error Message
579
+ */
580
+ static getErrorsREST(ex) {
581
+ const errors = super.getErrorsREST(ex);
582
+ if (errors?.length > 0) {
583
+ return errors.map((msg) =>
584
+ msg
585
+ .split('403 Forbidden')
586
+ .join('403 Forbidden: Please check if the automation is currently running.')
587
+ );
588
+ }
589
+ return errors;
590
+ }
591
+
592
+ /**
593
+ * a function to start query execution via API
594
+ *
595
+ * @param {string[]} keyArr customerkey of the metadata
596
+ * @returns {Promise.<string[]>} Returns list of keys that were paused
597
+ */
598
+ static async pause(keyArr) {
599
+ const metadataMap = {};
600
+ for (const key of keyArr) {
601
+ if (key) {
602
+ const results = await this.retrieve(undefined, undefined, undefined, key);
603
+ if (Object.keys(results.metadata).length) {
604
+ for (const key of Object.keys(results.metadata)) {
605
+ if (this.#isValidSchedule(results.metadata[key])) {
606
+ metadataMap[key] = results.metadata[key];
607
+ } else {
608
+ Util.logger.error(
609
+ ` - skipping ${this.definition.type} ${results.metadata[key].name}: no valid schedule settings found.`
610
+ );
611
+ }
612
+ }
613
+ }
614
+ }
615
+ }
616
+
617
+ Util.logger.info(`Pausing automations: ${Object.keys(metadataMap).length}`);
618
+ const promiseResults = [];
619
+ for (const key of Object.keys(metadataMap)) {
620
+ if (metadataMap[key].status === 'Scheduled') {
621
+ promiseResults.push(this.#pauseItem(metadataMap[key]));
622
+ } else if (metadataMap[key].status === 'PausedSchedule') {
623
+ Util.logger.info(
624
+ ` - skipping ${this.definition.type} ${metadataMap[key].name}: already paused.`
625
+ );
626
+ } else {
627
+ Util.logger.error(
628
+ ` - skipping ${this.definition.type} ${
629
+ metadataMap[key].name
630
+ }: currently ${metadataMap[
631
+ key
632
+ ].status.toLowerCase()}. Please try again in a few minutes.`
633
+ );
634
+ }
635
+ }
636
+ const pausedKeyArr = (await Promise.all(promiseResults))
637
+ .filter(Boolean)
638
+ .filter((r) => r.response.OverallStatus === 'OK')
639
+ .map((r) => r.key);
640
+
641
+ Util.logger.info(`Paused ${pausedKeyArr.length} of ${keyArr.length} items`);
642
+ return pausedKeyArr;
643
+ }
644
+
645
+ /**
646
+ * helper for {@link Automation.pause}
647
+ *
648
+ * @param {TYPE.AutomationItem} metadata automation metadata
649
+ * @returns {Promise.<{key:string, response:object}>} metadata key and API response
650
+ */
651
+ static async #pauseItem(metadata) {
652
+ const schedule = {};
653
+ try {
654
+ const response = await this.client.soap.schedule(
655
+ 'Automation',
656
+ schedule,
657
+ {
658
+ Interaction: {
659
+ ObjectID: metadata[this.definition.idField],
660
+ },
661
+ },
662
+ 'pause',
663
+ {}
664
+ );
665
+ Util.logger.info(
666
+ ` - paused ${this.definition.type}: ${metadata[this.definition.keyField]} / ${
667
+ metadata[this.definition.nameField]
668
+ }`
669
+ );
670
+ return { key: metadata[this.definition.keyField], response };
671
+ } catch (ex) {
672
+ this._handleSOAPErrors(ex, 'pausing', metadata, false);
673
+ return null;
674
+ }
675
+ }
448
676
 
449
677
  /**
450
678
  * Deploys automation - the saved file is the original one due to large differences required for deployment
@@ -452,11 +680,10 @@ class Automation extends MetadataType {
452
680
  * @param {TYPE.AutomationMap} metadata metadata mapped by their keyField
453
681
  * @param {string} targetBU name/shorthand of target businessUnit for mapping
454
682
  * @param {string} retrieveDir directory where metadata after deploy should be saved
455
- * @param {boolean} [isRefresh] optional flag - so far not used by automation
456
683
  * @returns {Promise.<TYPE.AutomationMap>} Promise
457
684
  */
458
- static async deploy(metadata, targetBU, retrieveDir, isRefresh) {
459
- const upsertResults = await this.upsert(metadata, targetBU, isRefresh);
685
+ static async deploy(metadata, targetBU, retrieveDir) {
686
+ const upsertResults = await this.upsert(metadata, targetBU);
460
687
  const savedMetadata = await this.saveResults(upsertResults, retrieveDir, null);
461
688
  if (
462
689
  this.properties.metaDataTypes.documentOnRetrieve.includes(this.definition.type) &&
@@ -488,11 +715,47 @@ class Automation extends MetadataType {
488
715
  * @returns {Promise} Promise
489
716
  */
490
717
  static update(metadata, metadataBefore) {
491
- metadata.id = metadataBefore.id;
718
+ if (metadataBefore.status === 'Running') {
719
+ Util.logger.error(
720
+ ` ☇ error updating ${this.definition.type} ${
721
+ metadata[this.definition.keyField] || metadata[this.definition.nameField]
722
+ } / ${
723
+ metadata[this.definition.nameField]
724
+ }: You cannot update an automation that's currently running. Please wait a bit and retry.`
725
+ );
726
+ return null;
727
+ }
492
728
  const uri = '/automation/v1/automations/' + metadata.id;
493
729
  return super.updateREST(metadata, uri);
494
730
  }
495
731
 
732
+ /**
733
+ * helper for {@link Automation.preDeployTasks} and {@link Automation.execute}
734
+ *
735
+ * @param {TYPE.AutomationItem} metadata metadata mapped by their keyField
736
+ */
737
+ static #preDeploySchedule(metadata) {
738
+ delete metadata.schedule.rangeTypeId;
739
+ delete metadata.schedule.pattern;
740
+ delete metadata.schedule.scheduledTime;
741
+ delete metadata.schedule.scheduledStatus;
742
+ if (this.definition.timeZoneMapping[metadata.schedule.timezoneName]) {
743
+ metadata.schedule.timezoneId =
744
+ this.definition.timeZoneMapping[metadata.schedule.timezoneName];
745
+ } else {
746
+ Util.logger.error(
747
+ `Could not find timezone ${metadata.schedule.timezoneName} in definition.timeZoneMapping`
748
+ );
749
+ }
750
+
751
+ // the upsert API needs this to be named scheduleTypeId; the retrieve API returns it as typeId
752
+ metadata.schedule.scheduleTypeId = metadata.schedule.typeId;
753
+ delete metadata.schedule.typeId;
754
+
755
+ // prep startSource
756
+ metadata.startSource = { schedule: metadata.schedule, typeId: 1 };
757
+ }
758
+
496
759
  /**
497
760
  * Gets executed before deploying metadata
498
761
  *
@@ -516,25 +779,12 @@ class Automation extends MetadataType {
516
779
  if (metadata.type === 'scheduled' && metadata?.schedule?.startDate) {
517
780
  // Starting Source == 'Schedule'
518
781
 
519
- delete metadata.schedule.rangeTypeId;
520
- delete metadata.schedule.pattern;
521
- delete metadata.schedule.scheduledTime;
522
- delete metadata.schedule.scheduledStatus;
523
- if (this.definition.timeZoneMapping[metadata.schedule.timezoneName]) {
524
- metadata.schedule.timezoneId =
525
- this.definition.timeZoneMapping[metadata.schedule.timezoneName];
526
- } else {
527
- Util.logger.error(
528
- `Could not find timezone ${metadata.schedule.timezoneName} in definition.timeZoneMapping`
529
- );
530
- }
531
- delete metadata.schedule.timezoneName;
532
- // the upsert API needs this to be named scheduleTypeId; the retrieve API returns it as typeId
533
- metadata.schedule.scheduleTypeId = metadata.schedule.typeId;
534
- delete metadata.schedule.typeId;
782
+ this.#preDeploySchedule(metadata);
783
+ // * run _buildSchedule here but only to check if things look ok - do not use the returned schedule object for deploy
784
+ this._buildSchedule(metadata.schedule);
535
785
 
536
- // prep startSource
537
- metadata.startSource = { schedule: metadata.schedule, typeId: 1 };
786
+ delete metadata.schedule.timezoneName;
787
+ delete metadata.startSource.schedule.timezoneName;
538
788
  } else if (metadata.type === 'triggered' && metadata.fileTrigger) {
539
789
  // Starting Source == 'File Drop'
540
790
 
@@ -632,6 +882,16 @@ class Automation extends MetadataType {
632
882
  }
633
883
  return deployable;
634
884
  }
885
+ /**
886
+ * helper for {@link MetadataType.updateREST} and {@link MetadataType.updateSOAP} that removes old files after the key was changed
887
+ *
888
+ * @private
889
+ * @param {TYPE.MetadataTypeItem} metadataEntry a single metadata Entry
890
+ * @returns {void}
891
+ */
892
+ static async _postChangeKeyTasks(metadataEntry) {
893
+ super._postChangeKeyTasks(metadataEntry, true);
894
+ }
635
895
 
636
896
  /**
637
897
  * Gets executed after deployment of metadata type
@@ -642,21 +902,53 @@ class Automation extends MetadataType {
642
902
  */
643
903
  static async postDeployTasks(metadataMap, originalMetadataMap) {
644
904
  for (const key in metadataMap) {
905
+ const item = metadataMap[key];
906
+
907
+ const oldKey = Util.changedKeysMap?.[this.definition.type]?.[key] || key;
908
+ delete Util.changedKeysMap?.[this.definition.type]?.[key];
909
+
910
+ if (!item.type) {
911
+ // create response does not return the type attribute
912
+
913
+ const scheduleHelper = item.schedule || item.startSource.schedule;
914
+
915
+ // el.type
916
+ item.type = scheduleHelper
917
+ ? 'scheduled'
918
+ : item.fileTrigger
919
+ ? 'triggered'
920
+ : undefined;
921
+
922
+ // el.schedule.timezoneName
923
+ if (item.type === 'scheduled') {
924
+ // not existing for triggered automations
925
+ scheduleHelper.timezoneName ||= Util.inverseGet(
926
+ this.definition.timeZoneMapping,
927
+ scheduleHelper.timezoneId
928
+ );
929
+ }
930
+
931
+ // el.status
932
+ item.status ||= Util.inverseGet(this.definition.statusMapping, item.statusId);
933
+ }
645
934
  // need to put schedule on here if status is scheduled
646
- await Automation.#scheduleAutomation(metadataMap, originalMetadataMap, key);
935
+ await Automation.#scheduleAutomation(metadataMap, originalMetadataMap, key, oldKey);
647
936
 
648
937
  // need to update notifications separately if there are any
649
938
  await Automation.#updateNotificationInfoREST(metadataMap, key);
650
939
 
651
940
  // rewrite upsert to retrieve fields
652
- const metadata = metadataMap[key];
653
- if (metadata.steps) {
654
- for (const step of metadata.steps) {
941
+ if (item.steps) {
942
+ for (const step of item.steps) {
655
943
  step.name = step.annotation;
656
944
  delete step.annotation;
657
945
  }
658
946
  }
659
947
  }
948
+ if (Util.OPTIONS.execute || Util.OPTIONS.schedule) {
949
+ Util.logger.info(`Executing: ${this.definition.type}`);
950
+ await this.execute(Object.keys(metadataMap));
951
+ }
660
952
  }
661
953
  /**
662
954
  * helper for {@link Automation.postDeployTasks}
@@ -706,22 +998,26 @@ class Automation extends MetadataType {
706
998
  }
707
999
 
708
1000
  /**
709
- * helper for {@link postDeployTasks}
1001
+ * helper for {@link Automation.postDeployTasks}
710
1002
  *
711
1003
  * @param {TYPE.AutomationMap} metadataMap metadata mapped by their keyField
712
1004
  * @param {TYPE.AutomationMap} originalMetadataMap metadata to be updated (contains additioanl fields)
713
1005
  * @param {string} key current customer key
1006
+ * @param {string} [oldKey] old customer key before fixKey / changeKeyValue / changeKeyField
1007
+ * @returns {Promise.<{key:string, response:object}>} metadata key and API response
714
1008
  */
715
- static async #scheduleAutomation(metadataMap, originalMetadataMap, key) {
716
- if (originalMetadataMap[key]?.type === 'scheduled') {
1009
+ static async #scheduleAutomation(metadataMap, originalMetadataMap, key, oldKey) {
1010
+ let response = null;
1011
+ oldKey ||= key;
1012
+ if (originalMetadataMap[oldKey]?.type === 'scheduled') {
717
1013
  // Starting Source == 'Schedule': Try starting the automation
718
- if (originalMetadataMap[key].status === 'Scheduled') {
1014
+ if (originalMetadataMap[oldKey].status === 'Scheduled') {
719
1015
  let schedule = null;
720
1016
  try {
721
- schedule = this._buildSchedule(originalMetadataMap[key].schedule);
1017
+ schedule = this._buildSchedule(originalMetadataMap[oldKey].schedule);
722
1018
  } catch (ex) {
723
1019
  Util.logger.error(
724
- `- Could not create schedule for automation '${originalMetadataMap[key].name}' to start it: ${ex.message}`
1020
+ `- Could not create schedule for automation '${originalMetadataMap[oldKey].name}' to start it: ${ex.message}`
725
1021
  );
726
1022
  }
727
1023
  if (schedule !== null) {
@@ -734,12 +1030,12 @@ class Automation extends MetadataType {
734
1030
  const schedule_timezoneString = schedule._timezoneString;
735
1031
  delete schedule._timezoneString;
736
1032
  // start the automation
737
- await this.client.soap.schedule(
1033
+ response = await this.client.soap.schedule(
738
1034
  'Automation',
739
1035
  schedule,
740
1036
  {
741
1037
  Interaction: {
742
- ObjectID: metadataMap[key].id,
1038
+ ObjectID: metadataMap[key][this.definition.idField],
743
1039
  },
744
1040
  },
745
1041
  'start',
@@ -753,21 +1049,22 @@ class Automation extends MetadataType {
753
1049
  (schedule_interval > 1 ? 's' : ''));
754
1050
  Util.logger.warn(
755
1051
  ` - scheduled automation '${
756
- originalMetadataMap[key].name
1052
+ originalMetadataMap[oldKey].name
757
1053
  }' deployed as Active: runs every ${intervalString} starting ${
758
1054
  schedule_StartDateTime.split('T').join(' ').split('.')[0]
759
1055
  } ${schedule_timezoneString}`
760
1056
  );
761
- } catch (ex) {
1057
+ } catch {
1058
+ // API does not return anything usefull here. We have to know the rules instead
762
1059
  Util.logger.error(
763
- `- Could not start scheduled automation '${originalMetadataMap[key].name}': ${ex.message}`
1060
+ ` error starting scheduled ${this.definition.type}${key}: Please check schedule settings`
764
1061
  );
765
1062
  }
766
1063
  }
767
1064
  } else {
768
1065
  Util.logger.info(
769
1066
  Util.getGrayMsg(
770
- ` - scheduled automation '${originalMetadataMap[key].name}' deployed as Paused`
1067
+ ` - scheduled automation '${originalMetadataMap[oldKey].name}' deployed as Paused`
771
1068
  )
772
1069
  );
773
1070
  }
@@ -781,6 +1078,7 @@ class Automation extends MetadataType {
781
1078
  metadataMap[key].schedule.typeId = metadataMap[key].schedule.scheduleTypeId;
782
1079
  delete metadataMap[key].schedule.scheduleTypeId;
783
1080
  }
1081
+ return { key, response };
784
1082
  }
785
1083
 
786
1084
  /**
@@ -867,6 +1165,9 @@ class Automation extends MetadataType {
867
1165
  const a = obj.split('=');
868
1166
  recurHelper[a[0]] = a[1];
869
1167
  }
1168
+ if (recurHelper.INTERVAL) {
1169
+ recurHelper.INTERVAL = Number.parseInt(recurHelper.INTERVAL);
1170
+ }
870
1171
  // the ical schedule is all in caps but soap objects require Title Case.
871
1172
  const keyStem = recurHelper.FREQ.charAt(0) + recurHelper.FREQ.slice(1, -2).toLowerCase();
872
1173
 
@@ -895,13 +1196,18 @@ class Automation extends MetadataType {
895
1196
  'Scheduling automatically not supported for Weekly, Monthly and Yearly, please configure manually.'
896
1197
  );
897
1198
  }
1199
+ if (recurHelper.FREQ === 'MINUTELY' && recurHelper.INTERVAL && recurHelper.INTERVAL < 5) {
1200
+ throw new Error(
1201
+ 'The smallest interval you can configure is 5 minutes. Please adjust your schedule.'
1202
+ );
1203
+ }
898
1204
 
899
1205
  if (this.definition.timeZoneMapping[scheduleObject.timezoneName]) {
900
1206
  scheduleObject.timezoneId =
901
1207
  this.definition.timeZoneMapping[scheduleObject.timezoneName];
902
1208
  } else {
903
- Util.logger.error(
904
- `- Could not find timezone ${scheduleObject.timezoneName} in definition.timeZoneMapping`
1209
+ throw new Error(
1210
+ `Could not find timezone ${scheduleObject.timezoneName} in definition.timeZoneMapping`
905
1211
  );
906
1212
  }
907
1213
  schedule.TimeZone.ID = scheduleObject.timezoneId;
@@ -995,8 +1301,8 @@ class Automation extends MetadataType {
995
1301
  // create new Date object reflecting SFMC's servertime
996
1302
  const dateServer = new Date(utc + 3600000 * offsetServer);
997
1303
 
998
- // return time as a string without trailing "Z"
999
- return dateServer.toISOString().slice(0, -1);
1304
+ // return time as a string without trailing "Z" and without miliseconds (separated by .)
1305
+ return dateServer.toISOString().slice(0, -1).split('.')[0];
1000
1306
  }
1001
1307
  /**
1002
1308
  * Experimental: Only working for DataExtensions:
@@ -1023,7 +1329,7 @@ class Automation extends MetadataType {
1023
1329
  const automationType = { scheduled: 'Schedule', triggered: 'File Drop' };
1024
1330
  output += `**Started by:** ${automationType[json.type] || 'Not defined'}\n\n`;
1025
1331
  output += `**Status:** ${json.status}\n\n`;
1026
- if (json.type === 'scheduled') {
1332
+ if (json.type === 'scheduled' || json.schedule) {
1027
1333
  const tz =
1028
1334
  this.definition.timeZoneDifference[
1029
1335
  this.definition.timeZoneMapping[json?.schedule?.timezoneName]
@@ -1033,7 +1339,7 @@ class Automation extends MetadataType {
1033
1339
  output += `**Schedule:**\n\n`;
1034
1340
  output += `* Start: ${json.schedule.startDate.split('T').join(' ')} ${tz}\n`;
1035
1341
  output += `* End: ${json.schedule.endDate.split('T').join(' ')} ${tz}\n`;
1036
- output += `* Timezone: ${json.schedule.timezoneName}\n`;
1342
+ output += `* Timezone: ${json.schedule.timezoneName}\n`;
1037
1343
 
1038
1344
  const ical = {};
1039
1345
  for (const item of json.schedule.icalRecur.split(';')) {
@@ -1042,9 +1348,20 @@ class Automation extends MetadataType {
1042
1348
  }
1043
1349
  const frequency = ical.FREQ.slice(0, -2).toLowerCase();
1044
1350
 
1045
- output += `* Recurrance: every ${ical.INTERVAL > 1 ? ical.INTERVAL : ''} ${
1046
- frequency === 'dai' ? 'day' : frequency
1047
- }${ical.INTERVAL > 1 ? 's' : ''} ${ical.COUNT ? `for ${ical.COUNT} times` : ''}\n`;
1351
+ output += `* Recurrance: `;
1352
+ output +=
1353
+ ical.COUNT == 1
1354
+ ? 'run only once'
1355
+ : `every${ical.INTERVAL > 1 ? ' ' + ical.INTERVAL : ''} ${
1356
+ frequency === 'dai' ? 'day' : frequency
1357
+ }${ical.INTERVAL > 1 ? 's' : ''}${
1358
+ ical.COUNT
1359
+ ? ` for ${ical.COUNT} times`
1360
+ : ical.UNTIL
1361
+ ? ' until end date'
1362
+ : ''
1363
+ }`;
1364
+ output += '\n';
1048
1365
  } else if (json.schedule) {
1049
1366
  output += `**Schedule:** Not defined\n`;
1050
1367
  }
@@ -1224,7 +1541,7 @@ class Automation extends MetadataType {
1224
1541
  * @param {string} key customer key
1225
1542
  * @returns {Promise.<string>} objectId or enpty string
1226
1543
  */
1227
- static async _getObjectIdForSingleRetrieve(key) {
1544
+ static async #getObjectIdForSingleRetrieve(key) {
1228
1545
  const response = await this.client.soap.retrieve('Program', ['ObjectID'], {
1229
1546
  filter: {
1230
1547
  leftOperand: 'CustomerKey',
@@ -1243,7 +1560,7 @@ class Automation extends MetadataType {
1243
1560
  */
1244
1561
  static async deleteByKey(customerKey) {
1245
1562
  // the delete endpoint returns a general exception if the automation does not exist; handle it gracefully instead by adding a retrieve first
1246
- const objectId = customerKey ? await this._getObjectIdForSingleRetrieve(customerKey) : null;
1563
+ const objectId = customerKey ? await this.#getObjectIdForSingleRetrieve(customerKey) : null;
1247
1564
  if (!objectId) {
1248
1565
  Util.logger.error(` - automation not found`);
1249
1566
  return false;