mcdev 5.0.2 → 5.1.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 (88) hide show
  1. package/.coverage-comment-template.svelte +177 -161
  2. package/.github/ISSUE_TEMPLATE/bug.yml +1 -0
  3. package/.github/dependabot.yml +8 -0
  4. package/.github/workflows/coverage-base-update.yml +6 -2
  5. package/.github/workflows/coverage-develop-branch.yml +7 -6
  6. package/.github/workflows/coverage-main-branch.yml +7 -6
  7. package/.github/workflows/coverage.yml +7 -2
  8. package/.husky/post-checkout +3 -2
  9. package/docs/dist/documentation.md +162 -47
  10. package/lib/Deployer.js +3 -3
  11. package/lib/cli.js +28 -0
  12. package/lib/index.js +173 -2
  13. package/lib/metadataTypes/Automation.js +400 -193
  14. package/lib/metadataTypes/DataExtension.js +5 -5
  15. package/lib/metadataTypes/MetadataType.js +42 -15
  16. package/lib/metadataTypes/Query.js +26 -0
  17. package/lib/metadataTypes/definitions/Asset.definition.js +1 -0
  18. package/lib/metadataTypes/definitions/Automation.definition.js +52 -6
  19. package/lib/metadataTypes/definitions/DataExtension.definition.js +1 -0
  20. package/lib/metadataTypes/definitions/DataExtract.definition.js +1 -0
  21. package/lib/metadataTypes/definitions/EmailSend.definition.js +1 -0
  22. package/lib/metadataTypes/definitions/Event.definition.js +1 -0
  23. package/lib/metadataTypes/definitions/Filter.definition.js +1 -0
  24. package/lib/metadataTypes/definitions/ImportFile.definition.js +1 -0
  25. package/lib/metadataTypes/definitions/MobileKeyword.definition.js +1 -0
  26. package/lib/metadataTypes/definitions/Query.definition.js +1 -0
  27. package/lib/metadataTypes/definitions/Role.definition.js +1 -0
  28. package/lib/metadataTypes/definitions/TriggeredSend.definition.js +1 -0
  29. package/lib/metadataTypes/definitions/User.definition.js +1 -0
  30. package/lib/util/devops.js +13 -11
  31. package/lib/util/util.js +152 -129
  32. package/package.json +5 -5
  33. package/test/general.test.js +26 -0
  34. package/test/mockRoot/.mcdevrc.json +1 -1
  35. package/test/mockRoot/deploy/testInstance/testBU/automation/testExisting_automation.automation-meta.json +53 -0
  36. package/test/mockRoot/deploy/testInstance/testBU/automation/testNew_automation.automation-meta.json +46 -0
  37. package/test/mockRoot/deploy/testInstance/testBU/query/{testExistingQuery.query-meta.json → testExisting_query.query-meta.json} +2 -2
  38. package/test/mockRoot/deploy/testInstance/testBU/query/{testNewQuery.query-meta.json → testNew_query.query-meta.json} +2 -2
  39. package/test/resourceFactory.js +64 -21
  40. package/test/resources/9999999/automation/build-expected.json +58 -0
  41. package/test/resources/9999999/automation/create-expected.json +46 -0
  42. package/test/resources/9999999/automation/create-testNew_automation-expected.md +28 -0
  43. package/test/resources/9999999/automation/delete-response.xml +40 -0
  44. package/test/resources/9999999/automation/retrieve-expected.json +58 -0
  45. package/test/resources/9999999/automation/retrieve-testExisting_automation-expected.md +30 -0
  46. package/test/resources/9999999/automation/template-expected.json +58 -0
  47. package/test/resources/9999999/automation/update-expected.json +46 -0
  48. package/test/resources/9999999/automation/update-testExisting_automation-expected.md +28 -0
  49. package/test/resources/9999999/automation/v1/automations/08afb0e2-b00a-4c88-ad2e-1f7f8788c560/get-response.json +85 -0
  50. package/test/resources/9999999/automation/v1/automations/08afb0e2-b00a-4c88-ad2e-1f7f8788c560/patch-response.json +85 -0
  51. package/test/resources/9999999/automation/v1/automations/a8afb0e2-b00a-4c88-ad2e-1f7f8788c560/get-response.json +85 -0
  52. package/test/resources/9999999/automation/v1/automations/post-response.json +85 -0
  53. package/test/resources/9999999/automation/v1/dataextracts/56c5370a-f988-4f36-b0ee-0f876573f6d7/get-response.json +38 -0
  54. package/test/resources/9999999/automation/v1/dataextracts/get-response.json +20 -0
  55. package/test/resources/9999999/automation/v1/filetransfers/72c328ac-f5b0-4e37-91d3-a775666f15a6/get-response.json +18 -0
  56. package/test/resources/9999999/automation/v1/filetransfers/get-response.json +15 -0
  57. package/test/resources/9999999/automation/v1/imports/get-response.json +38 -0
  58. package/test/resources/9999999/automation/v1/queries/549f0568-607c-4940-afef-437965094dat/actions/start/post-response.txt +1 -0
  59. package/test/resources/9999999/automation/v1/queries/549f0568-607c-4940-afef-437965094dat/get-response.json +2 -2
  60. package/test/resources/9999999/automation/v1/queries/549f0568-607c-4940-afef-437965094dat/patch-response.json +2 -2
  61. package/test/resources/9999999/automation/v1/queries/get-response.json +4 -4
  62. package/test/resources/9999999/automation/v1/queries/post-response.json +2 -2
  63. package/test/resources/9999999/automation/v1/scripts/get-response.json +17 -0
  64. package/test/resources/9999999/dataFolder/retrieve-ContentType=automations-response.xml +48 -0
  65. package/test/resources/9999999/dataFolder/retrieve-ContentType=queryactivity-response.xml +48 -0
  66. package/test/resources/9999999/dataFolder/retrieve-response.xml +22 -0
  67. package/test/resources/9999999/emailSendDefinition/retrieve-response.xml +85 -0
  68. package/test/resources/9999999/legacy/v1/beta/automations/notifications/RkpOcE9qSVh2VUdnYTVJbWFfWW14dzoyNTow/get-response.json +21 -0
  69. package/test/resources/9999999/legacy/v1/beta/automations/notifications/RkpOcE9qSVh2VUdnYTVJbWFfWW14dzoyNTow/post-response.json +0 -0
  70. package/test/resources/9999999/legacy/v1/beta/bulk/automations/automation/definition/get-response.json +30 -0
  71. package/test/resources/9999999/program/retrieve-CustomerKey=testExisting_automation-response.xml +30 -0
  72. package/test/resources/9999999/program/retrieve-CustomerKey=testNew_automation-response.xml +30 -0
  73. package/test/resources/9999999/program/retrieve-Name=testExisting_automation-response.xml +31 -0
  74. package/test/resources/9999999/program/retrieve-response.xml +32 -0
  75. package/test/resources/9999999/query/build-expected.json +2 -2
  76. package/test/resources/9999999/query/get-expected.json +2 -2
  77. package/test/resources/9999999/query/get2-expected.json +2 -2
  78. package/test/resources/9999999/query/patch-expected.json +2 -2
  79. package/test/resources/9999999/query/post-expected.json +2 -2
  80. package/test/resources/9999999/query/template-expected.json +2 -2
  81. package/test/type.automation.test.js +259 -0
  82. package/test/type.dataExtension.test.js +3 -0
  83. package/test/type.query.test.js +39 -26
  84. package/test/type.user.test.js +17 -3
  85. package/test/utils.js +7 -6
  86. package/.coverage-comment-template.md +0 -20
  87. /package/test/mockRoot/deploy/testInstance/testBU/query/{testExistingQuery.query-meta.sql → testExisting_query.query-meta.sql} +0 -0
  88. /package/test/mockRoot/deploy/testInstance/testBU/query/{testNewQuery.query-meta.sql → testNew_query.query-meta.sql} +0 -0
@@ -59,8 +59,8 @@ class DataExtension extends MetadataType {
59
59
  // output error & remove from deploy list
60
60
  Util.logger.error(
61
61
  ` ☇ skipping ${this.definition.type} ${
62
- metadataMap[this.definition.keyField]
63
- } / ${metadataMap[this.definition.nameField]}: ${ex.message}`
62
+ metadataMap[metadataKey][this.definition.keyField]
63
+ } / ${metadataMap[metadataKey][this.definition.nameField]}: ${ex.message}`
64
64
  );
65
65
  delete metadataMap[metadataKey];
66
66
  // skip rest of handling for this DE
@@ -624,16 +624,16 @@ class DataExtension extends MetadataType {
624
624
  * @returns {Promise.<TYPE.DataExtensionItem>} Promise of updated single DE
625
625
  */
626
626
  static async preDeployTasks(metadata) {
627
- if (metadata.Name.startsWith('_')) {
627
+ if (metadata.Name?.startsWith('_')) {
628
628
  throw new Error(`Cannot Upsert Strongly Typed Data Extensions`);
629
629
  }
630
630
  if (
631
631
  this.buObject.eid !== this.buObject.mid &&
632
- metadata.r__folder_Path.startsWith('Shared Items')
632
+ metadata.r__folder_Path?.startsWith('Shared Items')
633
633
  ) {
634
634
  throw new Error(`Cannot Create/Update a Shared Data Extension from the Child BU`);
635
635
  }
636
- if (metadata.r__folder_Path.startsWith('Synchronized Data Extensions')) {
636
+ if (metadata.r__folder_Path?.startsWith('Synchronized Data Extensions')) {
637
637
  throw new Error(
638
638
  `Cannot Create/Update a Synchronized Data Extension. Please use Contact Builder to maintain these`
639
639
  );
@@ -58,15 +58,9 @@ class MetadataType {
58
58
  if (key === fileNameWithoutEnding || listBadKeys) {
59
59
  fileName2FileContent[fileNameWithoutEnding] = fileContent;
60
60
  } else {
61
- Util.metadataLogger(
62
- 'error',
63
- this.definition.type,
64
- 'getJsonFromFS',
65
- 'Name of the Metadata and the External Identifier must match',
66
- JSON.stringify({
67
- Filename: fileNameWithoutEnding,
68
- ExternalIdentifier: key,
69
- })
61
+ Util.logger.error(
62
+ ` ${this.definition.type} ${key}: Name of the metadata file and the JSON-key (${this.definition.keyField}) must match` +
63
+ Util.getGrayMsg(` - ${dir}/${fileName}`)
70
64
  );
71
65
  }
72
66
  }
@@ -460,6 +454,18 @@ class MetadataType {
460
454
  return;
461
455
  }
462
456
 
457
+ /**
458
+ * Abstract execute method that needs to be implemented in child metadata type
459
+ *
460
+ * @returns {void}
461
+ */
462
+ static execute() {
463
+ Util.logger.error(
464
+ ` ☇ skipping ${this.definition.type}: execute is not supported yet for ${this.definition.type}`
465
+ );
466
+ return;
467
+ }
468
+
463
469
  /**
464
470
  * test if metadata was actually changed or not to potentially skip it during deployment
465
471
  *
@@ -650,6 +656,7 @@ class MetadataType {
650
656
  metadataMap[metadataKey][this.definition.keyField]
651
657
  );
652
658
  // Update if it already exists; Create it if not
659
+ const maxKeyLength = this.definition.maxKeyLength || 36;
653
660
  if (
654
661
  Util.logger.level === 'debug' &&
655
662
  metadataMap[metadataKey][this.definition.idField] &&
@@ -703,11 +710,11 @@ class MetadataType {
703
710
  // NOTE: trim twice while getting the newKey value to remove leading spaces before limiting the length
704
711
  const newKey = (metadataMap[metadataKey][Util.OPTIONS.changeKeyField] + '')
705
712
  .trim()
706
- .slice(0, 36)
713
+ .slice(0, maxKeyLength)
707
714
  .trim();
708
- if (metadataMap[metadataKey][Util.OPTIONS.changeKeyField] + '' > 36) {
715
+ if (metadataMap[metadataKey][Util.OPTIONS.changeKeyField] + '' > maxKeyLength) {
709
716
  Util.logger.warn(
710
- `Customer Keys may not exceed 36 characters. Truncated the value in field ${Util.OPTIONS.changeKeyField} to ${newKey}`
717
+ `${this.definition.type} ${this.definition.keyField} may not exceed ${maxKeyLength} characters. Truncated the value in field ${Util.OPTIONS.changeKeyField} to ${newKey}`
711
718
  );
712
719
  }
713
720
  if (metadataKey == newKey) {
@@ -727,10 +734,10 @@ class MetadataType {
727
734
  }
728
735
  } else if (Util.OPTIONS.changeKeyValue) {
729
736
  // NOTE: trim twice while getting the newKey value to remove leading spaces before limiting the length
730
- const newKey = Util.OPTIONS.changeKeyValue.trim().slice(0, 36).trim();
731
- if (Util.OPTIONS.changeKeyValue.trim().length > 36) {
737
+ const newKey = Util.OPTIONS.changeKeyValue.trim().slice(0, maxKeyLength).trim();
738
+ if (Util.OPTIONS.changeKeyValue.trim().length > maxKeyLength) {
732
739
  Util.logger.warn(
733
- `Customer Keys may not exceed 36 characters. Truncated your value to ${newKey}`
740
+ `${this.definition.type} ${this.definition.keyField} may not exceed ${maxKeyLength} characters. Truncated your value to ${newKey}`
734
741
  );
735
742
  }
736
743
  if (this.definition.keyField == this.definition.idField) {
@@ -1066,6 +1073,26 @@ class MetadataType {
1066
1073
  type: this.definition.type,
1067
1074
  };
1068
1075
  }
1076
+ /**
1077
+ * Used to execute a query/automation etc.
1078
+ *
1079
+ * @param {string} uri REST endpoint where the POST request should be sent
1080
+ * @param {string} key item key
1081
+ * @returns {Promise.<string>} 'OK' if started execution successfully, otherwise - 'Error'
1082
+ */
1083
+ static async executeREST(uri, key) {
1084
+ try {
1085
+ const response = await this.client.rest.post(uri, {}); // payload is empty for this request
1086
+ if (response === 'OK') {
1087
+ Util.logger.info(`Executed ${this.definition.type}: ${key}`);
1088
+ } else {
1089
+ throw new Error(response);
1090
+ }
1091
+ return response;
1092
+ } catch (ex) {
1093
+ Util.logger.error(`Failed to execute ${this.definition.type} ${key}: ${ex.message}`);
1094
+ }
1095
+ }
1069
1096
 
1070
1097
  /**
1071
1098
  * helper for {@link retrieveREST} and {@link retrieveSOAP}
@@ -50,6 +50,32 @@ class Query extends MetadataType {
50
50
  key
51
51
  );
52
52
  }
53
+ /**
54
+ * a function to start query execution via API
55
+ *
56
+ * @param {string[]} keyArr customerkey of the metadata
57
+ * @returns {Promise.<boolean>} Returns true if all items were executed successfully, otherwise false
58
+ */
59
+ static async execute(keyArr) {
60
+ const results = [];
61
+ // works only with objectId
62
+ let objectId;
63
+ for (const key of keyArr) {
64
+ if (key) {
65
+ objectId = await this._getObjectIdForSingleRetrieve(key);
66
+ if (!objectId) {
67
+ Util.logger.info(`Skipping ${key} - did not find an item with such key`);
68
+ break;
69
+ }
70
+ }
71
+ results.push(
72
+ super.executeREST(`/automation/v1/queries/${objectId}/actions/start/`, key)
73
+ );
74
+ }
75
+ const successCounter = (await Promise.all(results)).filter((r) => r === 'OK').length;
76
+ Util.logger.info(`Executed ${successCounter} of ${keyArr.length} items`);
77
+ return successCounter === keyArr.length;
78
+ }
53
79
  /**
54
80
  * helper to allow us to select single metadata entries via REST
55
81
  *
@@ -13,6 +13,7 @@ module.exports = {
13
13
  lastmodDateField: 'modifiedDate',
14
14
  lastmodNameField: 'modifiedBy.name',
15
15
  restPagination: true,
16
+ maxKeyLength: 36, // confirmed max length
16
17
  type: 'asset',
17
18
  typeDescription: 'Assets from Content Builder grouped into subtypes.',
18
19
  typeRetrieveByDefault: ['asset', 'code', 'textfile', 'block', 'message', 'template', 'other'],
@@ -46,7 +46,8 @@ module.exports = {
46
46
  createdNameField: 'createdByName',
47
47
  lastmodDateField: 'lastSavedDate',
48
48
  lastmodNameField: 'lastSavedByName',
49
- restPagination: false,
49
+ restPagination: true,
50
+ maxKeyLength: 200, // confirmed max length
50
51
  statusMapping: {
51
52
  AwaitingTrigger: 7,
52
53
  Building: 1,
@@ -329,7 +330,7 @@ module.exports = {
329
330
  legacyId: {
330
331
  isCreateable: false,
331
332
  isUpdateable: false,
332
- retrieving: true,
333
+ retrieving: false,
333
334
  template: false,
334
335
  },
335
336
  lastSavedDate: {
@@ -368,6 +369,45 @@ module.exports = {
368
369
  retrieving: true,
369
370
  template: true,
370
371
  },
372
+ notifications: {
373
+ isCreateable: true,
374
+ isUpdateable: true,
375
+ retrieving: true,
376
+ template: true,
377
+ },
378
+ 'notifications[].email': {
379
+ isCreateable: true,
380
+ isUpdateable: true,
381
+ retrieving: true,
382
+ template: true,
383
+ },
384
+ 'notifications[].message': {
385
+ isCreateable: true,
386
+ isUpdateable: true,
387
+ retrieving: true,
388
+ template: true,
389
+ },
390
+ 'notifications[].channelType': {
391
+ // always 'Account'
392
+ isCreateable: true,
393
+ isUpdateable: true,
394
+ retrieving: false,
395
+ template: false,
396
+ },
397
+ 'notifications[].type': {
398
+ // custom shorthand for channelType
399
+ isCreateable: true,
400
+ isUpdateable: true,
401
+ retrieving: true,
402
+ template: true,
403
+ },
404
+ 'notifications[].notificationType': {
405
+ // Error, Complete
406
+ isCreateable: true,
407
+ isUpdateable: true,
408
+ retrieving: true,
409
+ template: true,
410
+ },
371
411
  startSource: {
372
412
  skipValidation: true,
373
413
  },
@@ -411,7 +451,7 @@ module.exports = {
411
451
  isCreateable: true,
412
452
  isUpdateable: true,
413
453
  retrieving: false,
414
- template: true,
454
+ template: false,
415
455
  },
416
456
  'schedule.scheduleStatus': {
417
457
  isCreateable: false,
@@ -425,6 +465,12 @@ module.exports = {
425
465
  retrieving: true,
426
466
  template: true,
427
467
  },
468
+ 'schedule.statusId': {
469
+ isCreateable: false,
470
+ isUpdateable: false,
471
+ retrieving: false,
472
+ template: false,
473
+ },
428
474
  'schedule.timezoneId': {
429
475
  isCreateable: true,
430
476
  isUpdateable: true,
@@ -527,8 +573,8 @@ module.exports = {
527
573
  'steps[].annotation': {
528
574
  isCreateable: true,
529
575
  isUpdateable: true,
530
- retrieving: true,
531
- template: true,
576
+ retrieving: false,
577
+ template: false,
532
578
  },
533
579
  'steps[].id': {
534
580
  isCreateable: true,
@@ -564,7 +610,7 @@ module.exports = {
564
610
  isCreateable: true,
565
611
  isUpdateable: true,
566
612
  retrieving: false,
567
- template: true,
613
+ template: false,
568
614
  },
569
615
  r__folder_Path: { skipValidation: true },
570
616
  },
@@ -96,6 +96,7 @@ module.exports = {
96
96
  lastmodDateField: 'ModifiedDate',
97
97
  lastmodNameField: null,
98
98
  restPagination: false,
99
+ maxKeyLength: 200, // confirmed max length
99
100
  type: 'dataExtension',
100
101
  typeDescription: 'Database table schemas.',
101
102
  typeRetrieveByDefault: true,
@@ -11,6 +11,7 @@ module.exports = {
11
11
  lastmodNameField: 'modifiedBy',
12
12
  nameField: 'name',
13
13
  restPagination: true,
14
+ maxKeyLength: 36, // confirmed max length
14
15
  type: 'dataExtract',
15
16
  typeDescription: 'Creates zipped files in your FTP directory or convert XML into CSV.',
16
17
  typeRetrieveByDefault: true,
@@ -14,6 +14,7 @@ module.exports = {
14
14
  lastmodDateField: 'ModifiedDate',
15
15
  lastmodNameField: null,
16
16
  restPagination: null,
17
+ maxKeyLength: 36, // confirmed max length
17
18
  type: 'emailSend',
18
19
  soapType: 'emailSendDefinition',
19
20
  typeDescription: 'Mainly used in Automations as "Send Email Activity".',
@@ -11,6 +11,7 @@ module.exports = {
11
11
  lastmodDateField: 'modifiedDate',
12
12
  lastmodNameField: 'modifiedBy',
13
13
  restPagination: true,
14
+ maxKeyLength: 200, // confirmed max length
14
15
  type: 'event',
15
16
  typeDescription: 'Used in Journeys (Interactions) to define Entry Events.',
16
17
  typeRetrieveByDefault: true,
@@ -11,6 +11,7 @@ module.exports = {
11
11
  lastmodDateField: 'modifiedDate',
12
12
  lastmodNameField: null,
13
13
  restPagination: true,
14
+ maxKeyLength: 36, // confirmed max length
14
15
  type: 'filter',
15
16
  typeDescription:
16
17
  'BETA: Part of how filtered Data Extensions are created. Depends on type "FilterDefinitions".',
@@ -22,6 +22,7 @@ module.exports = {
22
22
  DataExtension: 255,
23
23
  Email: 0,
24
24
  },
25
+ maxKeyLength: 36, // confirmed max length
25
26
  type: 'importFile',
26
27
  typeDescription: 'Reads files in FTP directory for further processing.',
27
28
  typeRetrieveByDefault: true,
@@ -12,6 +12,7 @@ module.exports = {
12
12
  lastmodNameField: null,
13
13
  restPagination: true,
14
14
  restPageSize: 50,
15
+ maxKeyLength: 50, // assumed max length
15
16
  type: 'mobileKeyword',
16
17
  typeDescription: 'Used for managing subscriptions for Mobile numbers in Mobile Connect',
17
18
  typeRetrieveByDefault: true,
@@ -21,6 +21,7 @@ module.exports = {
21
21
  Overwrite: 0,
22
22
  Update: 1,
23
23
  },
24
+ maxKeyLength: 36, // confirmed max length
24
25
  type: 'query',
25
26
  typeDescription: 'Select & transform data using SQL.',
26
27
  typeRetrieveByDefault: true,
@@ -23,6 +23,7 @@ module.exports = {
23
23
  createdNameField: null,
24
24
  lastmodDateField: 'ModifiedDate',
25
25
  lastmodNameField: null,
26
+ maxKeyLength: 36, // confirmed max length
26
27
  type: 'role',
27
28
  typeDescription:
28
29
  'User Roles define groups that are used to grant users access to SFMC systems.',
@@ -21,6 +21,7 @@ module.exports = {
21
21
  lastmodDateField: 'ModifiedDate',
22
22
  lastmodNameField: null,
23
23
  restPagination: null,
24
+ maxKeyLength: 36, // confirmed max length
24
25
  type: 'triggeredSend',
25
26
  soapType: 'triggeredSendDefinition',
26
27
  typeDescription: 'DEPRECATED: Sends emails via API or DataExtension Event.',
@@ -12,6 +12,7 @@ module.exports = {
12
12
  createdNameField: null,
13
13
  lastmodDateField: 'ModifiedDate',
14
14
  lastmodNameField: 'Client.ModifiedBy',
15
+ maxKeyLength: 50, // confirmed max length
15
16
  type: 'user',
16
17
  soapType: 'AccountUser',
17
18
  typeDescription: 'Marketing Cloud users',
@@ -293,17 +293,19 @@ const DevOps = {
293
293
  }
294
294
 
295
295
  /** @type {TYPE.DeltaPkgItem[]} */
296
- const copied = delta.map((file) =>
297
- File.copyFile(
298
- file.file,
299
- path
300
- .normalize(file.file)
301
- .replace(
302
- path.normalize(properties.directories.retrieve),
303
- path.normalize(properties.directories.deploy)
304
- )
305
- )
306
- );
296
+ const copied = delta
297
+ .filter((file) => !file.file.endsWith('.md')) // filter documentation files
298
+ .map((file) =>
299
+ File.copyFile(
300
+ file.file,
301
+ path
302
+ .normalize(file.file)
303
+ .replace(
304
+ path.normalize(properties.directories.retrieve),
305
+ path.normalize(properties.directories.deploy)
306
+ )
307
+ )
308
+ );
307
309
  const results = await Promise.all(copied);
308
310
  const failed = results.filter((result) => result.status === 'failed');
309
311
  const skipped = results.filter((result) => result.status === 'skipped');