@adobe/aio-commerce-lib-app 1.2.0 → 1.3.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.
@@ -18,9 +18,9 @@ import { t as inspect } from "./logging-XIUXDK5T.mjs";
18
18
  import camelcase from "camelcase";
19
19
  import { AdobeCommerceHttpClient, resolveCommerceHttpClientParams, resolveIoEventsHttpClientParams } from "@adobe/aio-commerce-lib-api";
20
20
  import { resolveAuthParams, resolveImsAuthParams } from "@adobe/aio-commerce-lib-auth";
21
- import { createCustomCommerceEventsApiClient, createEventProvider, createEventSubscription, getAllEventProviders, getAllEventSubscriptions, updateEventingConfiguration } from "@adobe/aio-commerce-lib-events/commerce";
22
- import { createCustomAdobeIoEventsApiClient, createEventMetadataForProvider, createEventProvider as createEventProvider$1, createRegistration, getAllEventProviders as getAllEventProviders$1, getAllRegistrations } from "@adobe/aio-commerce-lib-events/io-events";
23
- import { createCustomCommerceWebhooksApiClient, getWebhookList, subscribeWebhook } from "@adobe/aio-commerce-lib-webhooks/api";
21
+ import { createCustomCommerceEventsApiClient, createEventProvider, createEventSubscription, deleteEventProvider, deleteEventSubscription, getAllEventProviders, getAllEventSubscriptions, updateEventingConfiguration } from "@adobe/aio-commerce-lib-events/commerce";
22
+ import { createCustomAdobeIoEventsApiClient, createEventMetadataForProvider, createEventProvider as createEventProvider$1, createRegistration, deleteEventMetadataForProvider, deleteEventProvider as deleteEventProvider$1, deleteRegistration, getAllEventProviders as getAllEventProviders$1, getAllRegistrations } from "@adobe/aio-commerce-lib-events/io-events";
23
+ import { createCustomCommerceWebhooksApiClient, getWebhookList, subscribeWebhook, unsubscribeWebhook } from "@adobe/aio-commerce-lib-webhooks/api";
24
24
  import { HTTPError } from "ky";
25
25
 
26
26
  //#region source/management/installation/workflow/hooks.ts
@@ -47,8 +47,8 @@ function isBranchStep(step) {
47
47
  * ```typescript
48
48
  * const createProviders = defineLeafStep({
49
49
  * name: "providers",
50
- * meta: { label: "Create Providers", description: "Creates I/O Events providers" },
51
- * run: async ({ config, stepContext }) => {
50
+ * meta: { install: { label: "Create Providers", description: "Creates I/O Events providers" } },
51
+ * install: async ({ config, stepContext }) => {
52
52
  * const { eventsClient } = stepContext;
53
53
  * return eventsClient.createProvider(config.eventing);
54
54
  * },
@@ -61,7 +61,8 @@ function defineLeafStep(options) {
61
61
  name: options.name,
62
62
  meta: options.meta,
63
63
  when: options.when,
64
- run: options.run,
64
+ install: options.install,
65
+ uninstall: options.uninstall,
65
66
  validate: options.validate
66
67
  };
67
68
  }
@@ -72,7 +73,7 @@ function defineLeafStep(options) {
72
73
  * ```typescript
73
74
  * const eventing = defineBranchStep({
74
75
  * name: "eventing",
75
- * meta: { label: "Eventing", description: "Sets up I/O Events" },
76
+ * meta: { install: { label: "Eventing", description: "Sets up I/O Events" } },
76
77
  * when: hasEventing,
77
78
  * context: async (ctx) => ({ eventsClient: await createEventsClient(ctx) }),
78
79
  * children: [commerceEventsStep, externalEventsStep],
@@ -153,12 +154,12 @@ function createFailedState(base, error) {
153
154
  * tree structure with all steps set to "pending".
154
155
  */
155
156
  function createInitialState(options) {
156
- const { rootStep, config } = options;
157
+ const { rootStep, config, mode } = options;
157
158
  return {
158
159
  id: crypto.randomUUID(),
159
160
  startedAt: nowIsoString(),
160
161
  status: "in-progress",
161
- step: buildInitialStepStatus(rootStep, config, []),
162
+ step: buildInitialStepStatus(rootStep, config, [], mode),
162
163
  data: null
163
164
  };
164
165
  }
@@ -166,6 +167,19 @@ function createInitialState(options) {
166
167
  * Executes a workflow from an initial state. Returns the final state (never throws).
167
168
  */
168
169
  async function executeWorkflow(options) {
170
+ return executeWorkflowWithMode(options, "install");
171
+ }
172
+ /**
173
+ * Executes an uninstall workflow from an initial state. Returns the final state (never throws).
174
+ * Steps with an `uninstall` handler get it called; steps without are silently skipped.
175
+ */
176
+ async function executeUninstallWorkflow(options) {
177
+ return executeWorkflowWithMode(options, "uninstall");
178
+ }
179
+ /**
180
+ * Internal implementation shared by executeWorkflow and executeUninstallWorkflow.
181
+ */
182
+ async function executeWorkflowWithMode(options, mode) {
169
183
  const { rootStep, installationContext, config, initialState, hooks } = options;
170
184
  const step = structuredClone(initialState.step);
171
185
  const context = {
@@ -176,7 +190,8 @@ async function executeWorkflow(options) {
176
190
  step,
177
191
  data: null,
178
192
  error: null,
179
- hooks
193
+ hooks,
194
+ mode
180
195
  };
181
196
  await callHook(hooks, "onInstallationStart", snapshot(context));
182
197
  try {
@@ -205,18 +220,18 @@ async function executeWorkflow(options) {
205
220
  * Builds initial step status from a step definition.
206
221
  * Filters steps based on their `when` conditions.
207
222
  */
208
- function buildInitialStepStatus(step, config, parentPath) {
223
+ function buildInitialStepStatus(step, config, parentPath, mode) {
209
224
  const path = [...parentPath, step.name];
210
225
  const children = [];
211
226
  if (isBranchStep(step) && step.children.length > 0) for (const child of step.children) {
212
227
  if (child.when && !child.when(config)) continue;
213
- children.push(buildInitialStepStatus(child, config, path));
228
+ children.push(buildInitialStepStatus(child, config, path, mode));
214
229
  }
215
230
  return {
216
231
  id: crypto.randomUUID(),
217
232
  name: step.name,
218
233
  path,
219
- meta: step.meta,
234
+ meta: mode === "uninstall" && step.meta.uninstall ? step.meta.uninstall : step.meta.install,
220
235
  status: "pending",
221
236
  children
222
237
  };
@@ -280,13 +295,17 @@ async function executeBranchStep(step, stepStatus, inherited, context) {
280
295
  await executeStep(childStep, child, childContext, context);
281
296
  }
282
297
  }
283
- /** Executes a leaf step and stores its result. */
298
+ /** Executes a leaf step and stores its result, or runs uninstall if in uninstall mode. */
284
299
  async function executeLeafStep(step, stepStatus, inherited, context) {
285
300
  const executionContext = {
286
301
  ...context.installationContext,
287
302
  ...inherited
288
303
  };
289
- const result = await step.run(context.config, executionContext);
304
+ if (context.mode === "uninstall") {
305
+ if (step.uninstall) await step.uninstall(context.config, executionContext);
306
+ return;
307
+ }
308
+ const result = await step.install(context.config, executionContext);
290
309
  context.data ??= {};
291
310
  setAtPath(context.data, stepStatus.path, result);
292
311
  }
@@ -347,7 +366,7 @@ async function validateStep(step, config, context, parentPath) {
347
366
  return {
348
367
  name: step.name,
349
368
  path,
350
- meta: step.meta,
369
+ meta: step.meta.install,
351
370
  issues,
352
371
  children
353
372
  };
@@ -411,6 +430,12 @@ function aggregateSummary(result) {
411
430
 
412
431
  //#endregion
413
432
  //#region source/management/installation/custom-installation/custom-scripts.ts
433
+ function isCustomInstallationStepDefinition(obj) {
434
+ return typeof obj === "object" && obj !== null && "install" in obj && typeof obj.install === "function";
435
+ }
436
+ function isCustomInstallationStepHandler(obj) {
437
+ return typeof obj === "function";
438
+ }
414
439
  /**
415
440
  * Creates a leaf step for executing a single custom installation script.
416
441
  */
@@ -418,26 +443,48 @@ function createCustomScriptStep(scriptConfig) {
418
443
  const { script, name, description } = scriptConfig;
419
444
  return defineLeafStep({
420
445
  name: camelcase(name),
421
- meta: {
446
+ meta: { install: {
422
447
  label: name,
423
448
  description
424
- },
425
- run: async (config, context) => {
449
+ } },
450
+ install: async (config, context) => {
426
451
  const { logger } = context;
427
452
  const customScripts = context.customScripts || {};
428
453
  logger.info(`Executing custom installation script: ${name}`);
429
454
  logger.debug(`Script path: ${script}`);
430
455
  const scriptModule = customScripts[script];
431
456
  if (!scriptModule) throw new Error(`Script ${script} not found in customScripts context. Make sure the script is defined in the configuration and the action was generated with custom scripts support.`);
432
- if (typeof scriptModule !== "object" || !("default" in scriptModule)) throw new Error(`Script ${script} must export a default function. Use defineCustomInstallationStep helper.`);
433
- const runFunction = scriptModule.default;
434
- if (typeof runFunction !== "function") throw new Error(`Script ${script} default export must be a function, got ${typeof runFunction}`);
457
+ if (typeof scriptModule !== "object" || !("default" in scriptModule)) throw new Error(`Script ${script} must export a default function or object. Use defineCustomInstallationStep helper.`);
458
+ const defaultExport = scriptModule.default;
459
+ let runFunction = null;
460
+ if (isCustomInstallationStepHandler(defaultExport)) runFunction = defaultExport;
461
+ else if (isCustomInstallationStepDefinition(defaultExport)) runFunction = defaultExport.install;
462
+ if (runFunction === null) throw new Error(`Script ${script} default export must be a function or an object with an install method. Use defineCustomInstallationStep helper.`);
435
463
  const scriptResult = await runFunction(config, context);
436
464
  logger.info(`Successfully executed script: ${name}`);
437
465
  return {
438
466
  script,
439
467
  data: scriptResult
440
468
  };
469
+ },
470
+ uninstall: async (config, context) => {
471
+ const { logger } = context;
472
+ const customScripts = context.customScripts || {};
473
+ logger.debug(`Uninstalling custom script: ${name}`);
474
+ const scriptModule = customScripts[script];
475
+ if (!scriptModule) throw new Error(`Script ${script} not found in customScripts context. Make sure the script is defined in the configuration and the action was generated with custom scripts support.`);
476
+ const defaultExport = scriptModule.default;
477
+ if (!isCustomInstallationStepDefinition(defaultExport)) {
478
+ logger.debug(`Script ${script} does not export an uninstall function, skipping uninstall.`);
479
+ return;
480
+ }
481
+ const { uninstall } = defaultExport;
482
+ if (!uninstall) {
483
+ logger.debug(`Script ${script} does not export an uninstall function, skipping uninstall.`);
484
+ return;
485
+ }
486
+ await uninstall(config, context);
487
+ logger.info(`Successfully uninstalled script: ${name}`);
441
488
  }
442
489
  });
443
490
  }
@@ -458,8 +505,14 @@ function createCustomScriptSteps(config) {
458
505
  const customInstallationStepBase = defineBranchStep({
459
506
  name: "customInstallationSteps",
460
507
  meta: {
461
- label: "Custom Installation Steps",
462
- description: "Executes custom installation scripts defined in the application configuration"
508
+ install: {
509
+ label: "Custom Installation Steps",
510
+ description: "Executes custom installation scripts defined in the application configuration"
511
+ },
512
+ uninstall: {
513
+ label: "Custom Uninstallation Steps",
514
+ description: "Executes custom uninstallation scripts defined in the application configuration"
515
+ }
463
516
  },
464
517
  when: hasCustomInstallationSteps,
465
518
  children: []
@@ -481,32 +534,37 @@ function createCustomInstallationStep(config) {
481
534
  * Define a custom installation step with type-safe parameters.
482
535
  *
483
536
  * This helper provides type safety and IDE autocompletion for custom installation scripts.
484
- * The handler function receives properly typed `config` and `context` parameters.
537
+ * Accepts either a plain function (install only) or an object with `install` and optional
538
+ * `uninstall` handlers.
485
539
  *
486
- * @param handler - The installation step handler function
487
- * @returns The same handler function (for use as default export)
488
- *
489
- * @example
540
+ * @example Plain function (install only):
490
541
  * ```typescript
491
542
  * import { defineCustomInstallationStep } from "@adobe/aio-commerce-lib-app/management";
492
543
  *
493
544
  * export default defineCustomInstallationStep(async (config, context) => {
494
545
  * const { logger, params } = context;
495
- *
496
546
  * logger.info(`Setting up ${config.metadata.displayName}...`);
547
+ * return { status: "success" };
548
+ * });
549
+ * ```
497
550
  *
498
- * // Your installation logic here
499
- * // TypeScript will provide autocompletion for config and context
551
+ * @example Object form with install and uninstall:
552
+ * ```typescript
553
+ * import { defineCustomInstallationStep } from "@adobe/aio-commerce-lib-app/management";
500
554
  *
501
- * return {
502
- * status: "success",
503
- * message: "Setup completed",
504
- * };
555
+ * export default defineCustomInstallationStep({
556
+ * install: async (config, context) => {
557
+ * context.logger.info(`Registering ${config.metadata.displayName}...`);
558
+ * return { status: "success" };
559
+ * },
560
+ * uninstall: async (config, context) => {
561
+ * context.logger.info(`Removing ${config.metadata.displayName}...`);
562
+ * },
505
563
  * });
506
564
  * ```
507
565
  */
508
- function defineCustomInstallationStep(handler) {
509
- return handler;
566
+ function defineCustomInstallationStep(handlerOrDefinition) {
567
+ return handlerOrDefinition;
510
568
  }
511
569
 
512
570
  //#endregion
@@ -529,6 +587,24 @@ async function registerExtension(context) {
529
587
  logger.info(`Admin UI SDK extension registered successfully: ${response.extensionId}`);
530
588
  return response;
531
589
  }
590
+ /**
591
+ * Unregisters the extension from Commerce via DELETE /V1/adminuisdk/extension/:workspace_name/:extension_name.
592
+ * Best-effort: errors are logged as warnings and do not stop the uninstall workflow.
593
+ *
594
+ * @param context - The execution context providing the Commerce HTTP client and logger.
595
+ */
596
+ async function uninstallExtension(context) {
597
+ const { commerceClient, appData, logger } = context;
598
+ const extensionName = process.env.__OW_NAMESPACE;
599
+ const endpoint = `adminuisdk/extension/${appData.workspaceName}/${extensionName}`;
600
+ logger.info(`Unregistering Admin UI SDK extension "${extensionName}" from workspace "${appData.workspaceName}"...`);
601
+ try {
602
+ await commerceClient.delete(endpoint);
603
+ logger.info(`Admin UI SDK extension "${extensionName}" unregistered successfully.`);
604
+ } catch (error) {
605
+ logger.warn(`Failed to unregister Admin UI SDK extension "${extensionName}": ${stringifyError(error)}. Continuing uninstall.`);
606
+ }
607
+ }
532
608
 
533
609
  //#endregion
534
610
  //#region source/management/installation/admin-ui-sdk/utils.ts
@@ -548,17 +624,30 @@ const createAdminUiSdkStepContext = (installation) => {
548
624
  const registerExtensionStep = defineLeafStep({
549
625
  name: "register-extension",
550
626
  meta: {
551
- label: "Register Extension",
552
- description: "Registers the Admin UI SDK extension in Adobe Commerce"
627
+ install: {
628
+ label: "Register Extension",
629
+ description: "Registers the Admin UI SDK extension in Adobe Commerce"
630
+ },
631
+ uninstall: {
632
+ label: "Unregister Extension",
633
+ description: "Removes the Admin UI SDK extension from Adobe Commerce"
634
+ }
553
635
  },
554
- run: (_, context) => registerExtension(context)
636
+ install: (_, context) => registerExtension(context),
637
+ uninstall: (_, context) => uninstallExtension(context)
555
638
  });
556
639
  /** Branch step for setting up the Admin UI SDK extension registration. */
557
640
  const adminUiSdkStep = defineBranchStep({
558
641
  name: "admin-ui-sdk",
559
642
  meta: {
560
- label: "Admin UI SDK",
561
- description: "Registers the extension with Adobe Commerce Admin UI SDK"
643
+ install: {
644
+ label: "Admin UI SDK",
645
+ description: "Registers the extension with Adobe Commerce Admin UI SDK"
646
+ },
647
+ uninstall: {
648
+ label: "Admin UI SDK",
649
+ description: "Removes the extension from Adobe Commerce Admin UI SDK"
650
+ }
562
651
  },
563
652
  when: hasAdminUiSdk,
564
653
  context: createAdminUiSdkStepContext,
@@ -589,6 +678,17 @@ function generateInstanceId(metadata, provider, workspaceId) {
589
678
  return `${appId}-${provider.key ?? slugLabel}-${workspaceId}`.toLowerCase();
590
679
  }
591
680
  /**
681
+ * Old version of instanceId generator which can be not unique within the same ORG.
682
+ *
683
+ * @param metadata - The metadata of the application
684
+ * @param provider - The event provider for which to generate the instance ID
685
+ * @deprecated use {@link generateInstanceId} instead
686
+ */
687
+ function generateInstanceIdDeprecated(metadata, provider) {
688
+ const slugLabel = provider.label.toLowerCase().replace(/\s+/g, "-");
689
+ return `${metadata.id}-${provider.key ?? slugLabel}`.toLowerCase();
690
+ }
691
+ /**
592
692
  * Find an existing event provider by its instance ID.
593
693
  * @param allProviders - The list of all existing event providers.
594
694
  * @param instanceId - The instance ID to search for.
@@ -678,6 +778,37 @@ function findExistingSubscription(allSubscriptions, eventName) {
678
778
  return allSubscriptions.get(eventName) ?? null;
679
779
  }
680
780
  /**
781
+ * Builds the payload to send to Commerce when configuring Eventing.
782
+ * Returns `null` when no update call is needed.
783
+ *
784
+ * @param initialParams - Initial Commerce Eventing configuration parameters.
785
+ * @param existingData - Existing Commerce Eventing state from the API.
786
+ */
787
+ function getCommerceEventingConfigurationUpdateParams(initialParams, existingData) {
788
+ const { isDefaultProviderConfigured, isDefaultWorkspaceConfigurationEmpty } = existingData;
789
+ if (isDefaultProviderConfigured && !isDefaultWorkspaceConfigurationEmpty) return null;
790
+ const { workspace_configuration, ...configWithoutWorkspace } = initialParams;
791
+ let updateParams = { enabled: true };
792
+ if (isDefaultWorkspaceConfigurationEmpty) {
793
+ if (!workspace_configuration) throw new Error("Workspace configuration is required to enable Commerce Eventing when there is not an existing one.");
794
+ updateParams.workspace_configuration = workspace_configuration;
795
+ }
796
+ if (!isDefaultProviderConfigured) updateParams = {
797
+ ...updateParams,
798
+ ...configWithoutWorkspace
799
+ };
800
+ return updateParams;
801
+ }
802
+ /**
803
+ * Sanitizes a Commerce Eventing identifier.
804
+ * Preserves underscores, converts spaces to underscores, lowercases, and strips the rest.
805
+ *
806
+ * @param value - The raw identifier value to normalize.
807
+ */
808
+ function sanitizeEventingIdentifier(value) {
809
+ return value.toLowerCase().replace(/\s+/g, "_").replace(/[^a-z0-9_]/g, "");
810
+ }
811
+ /**
681
812
  * Creates a partially filled workspace configuration object based on the app credentials and parameters.
682
813
  * This configuration is used when creating an event provider in Commerce.
683
814
  *
@@ -917,37 +1048,19 @@ async function createOrGetIoEventRegistration(params, registrations) {
917
1048
  }
918
1049
  /**
919
1050
  * Ensures Commerce Eventing is configured with the given configuration, updating it if it already exists.
920
- * @param eventsClient
921
- * @param params
922
- * @param existingData
1051
+ * @param params - The parameters necessary to configure Commerce Eventing.
1052
+ * @param existingData - Existing Commerce Eventing data.
923
1053
  */
924
1054
  async function configureCommerceEventing(params, existingData) {
925
1055
  const { context, config } = params;
926
1056
  const { commerceEventsClient, logger } = context;
927
- const { isDefaultProviderConfigured, isDefaultWorkspaceConfigurationEmpty } = existingData;
928
1057
  logger.info("Starting configuration of the Commerce Eventing Module");
929
- if (isDefaultProviderConfigured && !isDefaultWorkspaceConfigurationEmpty) {
1058
+ const updateParams = getCommerceEventingConfigurationUpdateParams(config, existingData);
1059
+ if (updateParams === null) {
930
1060
  logger.info("Commerce Eventing Module is already configured, skipping configuration step.");
931
1061
  return;
932
1062
  }
933
- const { workspace_configuration, ...configWithoutWorkspace } = config;
934
- let updateParams = { enabled: true };
935
- if (isDefaultWorkspaceConfigurationEmpty) {
936
- if (!workspace_configuration) {
937
- const message = "Workspace configuration is required to enable Commerce Eventing when there is not an existing one.";
938
- logger.error(message);
939
- throw new Error(message);
940
- }
941
- updateParams.workspace_configuration = workspace_configuration;
942
- logger.info("Adding workspace configuration to Commerce Eventing configuration as it has not been set up yet.");
943
- }
944
- if (!isDefaultProviderConfigured) {
945
- logger.info("Default provider not configured, it will be created with the provided configuration.");
946
- updateParams = {
947
- ...updateParams,
948
- ...configWithoutWorkspace
949
- };
950
- }
1063
+ logger.info(`Updating Commerce Eventing Module configuration with the following data: [${Object.keys(updateParams).join(", ")}]`);
951
1064
  return commerceEventsClient.updateEventingConfiguration(updateParams).then((success) => {
952
1065
  if (success) {
953
1066
  logger.info("Commerce Eventing Module configured successfully.");
@@ -1099,10 +1212,6 @@ async function onboardCommerceEventing(params, existingData) {
1099
1212
  const { events, provider, workspaceConfiguration } = ioData;
1100
1213
  const instanceId = provider.instance_id;
1101
1214
  const subscriptions = [];
1102
- await configureCommerceEventing({
1103
- context,
1104
- config: { workspace_configuration: workspaceConfiguration }
1105
- }, existingData);
1106
1215
  const { workspace_configuration: _, ...commerceProviderData } = await createOrGetCommerceProvider({
1107
1216
  context,
1108
1217
  provider: {
@@ -1124,6 +1233,181 @@ async function onboardCommerceEventing(params, existingData) {
1124
1233
  subscriptions
1125
1234
  };
1126
1235
  }
1236
+ /**
1237
+ * Deletes all I/O Events registrations for the given provider.
1238
+ * Registration names are reconstructed deterministically using the same logic as during installation.
1239
+ * Errors are caught and logged so that uninstall remains best-effort.
1240
+ */
1241
+ async function deleteIoEventRegistrations(providerData, provider, events, registrations, context) {
1242
+ const { ioEventsClient, appData, logger, params: runtimeParams } = context;
1243
+ const appCredentials = {
1244
+ consumerOrgId: appData.consumerOrgId,
1245
+ projectId: appData.projectId,
1246
+ workspaceId: appData.workspaceId
1247
+ };
1248
+ const actionEventsMap = groupEventsByRuntimeActions(events);
1249
+ const registrationNames = new Set(Array.from(actionEventsMap.keys()).map((runtimeAction) => getRegistrationName(providerData, runtimeAction)));
1250
+ const providerRegistrations = registrations.filter((reg) => reg.client_id === runtimeParams.AIO_COMMERCE_AUTH_IMS_CLIENT_ID && registrationNames.has(reg.name));
1251
+ if (providerRegistrations.length === 0) {
1252
+ logger.info(`No I/O Events registrations found for provider "${provider.label}" (instance ID: "${providerData.instance_id}").`);
1253
+ return;
1254
+ }
1255
+ logger.info(`Deleting ${providerRegistrations.length} I/O Events registration(s) for provider "${provider.label}" (instance ID: "${providerData.instance_id}")...`);
1256
+ for (const registration of providerRegistrations) {
1257
+ logger.info(`Deleting registration "${registration.name}" (ID: ${registration.id})...`);
1258
+ try {
1259
+ await ioEventsClient.deleteRegistration({
1260
+ ...appCredentials,
1261
+ registrationId: registration.registration_id
1262
+ });
1263
+ logger.info(`Deleted registration "${registration.name}" (ID: ${registration.id}).`);
1264
+ } catch (error) {
1265
+ logger.warn(`Failed to delete registration "${registration.name}" (ID: ${registration.id}): ${stringifyError(error)}. Continuing uninstall.`);
1266
+ }
1267
+ }
1268
+ }
1269
+ /**
1270
+ * Deletes all event metadata entries from the given I/O Events provider.
1271
+ * Errors are caught and logged so that uninstall remains best-effort.
1272
+ */
1273
+ async function deleteIoEventMetadata(providerData, provider, context) {
1274
+ const { ioEventsClient, appData, logger } = context;
1275
+ const appCredentials = {
1276
+ consumerOrgId: appData.consumerOrgId,
1277
+ projectId: appData.projectId,
1278
+ workspaceId: appData.workspaceId
1279
+ };
1280
+ const eventMetadataList = providerData.metadata ?? [];
1281
+ if (eventMetadataList.length === 0) {
1282
+ logger.info(`No event metadata found for provider "${provider.label}" (ID: ${providerData.id}).`);
1283
+ return;
1284
+ }
1285
+ logger.info(`Deleting ${eventMetadataList.length} event metadata entry(s) for provider "${provider.label}" (ID: ${providerData.id})...`);
1286
+ for (const eventMetadata of eventMetadataList) {
1287
+ logger.info(`Deleting event metadata "${eventMetadata.event_code}" from provider "${providerData.id}"...`);
1288
+ try {
1289
+ await ioEventsClient.deleteEventMetadataForProvider({
1290
+ ...appCredentials,
1291
+ providerId: providerData.id,
1292
+ eventCode: eventMetadata.event_code
1293
+ });
1294
+ logger.info(`Deleted event metadata "${eventMetadata.event_code}" from provider "${providerData.id}".`);
1295
+ } catch (error) {
1296
+ logger.warn(`Failed to delete event metadata "${eventMetadata.event_code}" from provider "${providerData.id}": ${stringifyError(error)}. Continuing uninstall.`);
1297
+ }
1298
+ }
1299
+ }
1300
+ /**
1301
+ * Deletes a single I/O Events provider.
1302
+ * Errors are caught and logged so that uninstall remains best-effort.
1303
+ */
1304
+ async function deleteIoEventProvider(providerData, provider, context) {
1305
+ const { ioEventsClient, appData, logger } = context;
1306
+ const appCredentials = {
1307
+ consumerOrgId: appData.consumerOrgId,
1308
+ projectId: appData.projectId,
1309
+ workspaceId: appData.workspaceId
1310
+ };
1311
+ logger.info(`Deleting I/O Events provider "${provider.label}" (ID: ${providerData.id})...`);
1312
+ try {
1313
+ await ioEventsClient.deleteEventProvider({
1314
+ ...appCredentials,
1315
+ providerId: providerData.id
1316
+ });
1317
+ logger.info(`Deleted I/O Events provider "${provider.label}" (ID: ${providerData.id}).`);
1318
+ } catch (error) {
1319
+ logger.warn(`Failed to delete I/O Events provider "${provider.label}" (ID: ${providerData.id}): ${stringifyError(error)}. Continuing uninstall.`);
1320
+ }
1321
+ }
1322
+ /**
1323
+ * Offboards a single event source from I/O Events by deleting, in order:
1324
+ * 1. All registrations that reference events from this provider.
1325
+ * 2. All event metadata entries on the provider.
1326
+ * 3. The provider itself.
1327
+ *
1328
+ * This is the reverse of {@link onboardIoEvents} and is called during uninstall.
1329
+ * All deletion errors are caught and logged so that uninstall remains best-effort.
1330
+ *
1331
+ * @param params - Configuration identifying the provider to offboard.
1332
+ * @param existingData - Current I/O Events data (providers and registrations).
1333
+ */
1334
+ async function offboardIoEvents(params, existingData) {
1335
+ const { context, metadata, provider, events } = params;
1336
+ const { appData, logger } = context;
1337
+ const instanceId = generateInstanceId(metadata, provider, appData.workspaceId);
1338
+ const instanceIdOldVersion = generateInstanceIdDeprecated(metadata, provider);
1339
+ const providerData = existingData.providersWithMetadata.find((p) => p.instance_id === instanceId || p.instance_id === instanceIdOldVersion);
1340
+ if (!providerData) {
1341
+ logger.info(`No I/O Events provider found with instance ID "${instanceId}", skipping offboarding.`);
1342
+ return;
1343
+ }
1344
+ await deleteIoEventRegistrations(providerData, provider, events, existingData.registrations, context);
1345
+ await deleteIoEventMetadata(providerData, provider, context);
1346
+ await deleteIoEventProvider(providerData, provider, context);
1347
+ }
1348
+ /**
1349
+ * Deletes all Commerce event subscriptions for the given events.
1350
+ * Subscriptions are matched by their namespaced name, built the same way as during installation.
1351
+ * Errors are caught and logged so that uninstall remains best-effort.
1352
+ */
1353
+ async function deleteCommerceEventSubscriptions(events, metadata, provider, existingSubscriptions, context) {
1354
+ const { commerceEventsClient, logger } = context;
1355
+ logger.info(`Unsubscribing Commerce event subscriptions for provider "${provider.label}"...`);
1356
+ for (const event of events) {
1357
+ const eventName = getNamespacedEvent(metadata, event.name);
1358
+ if (!existingSubscriptions.has(eventName)) {
1359
+ logger.info(`No Commerce subscription found for event "${event.name}" (namespaced: "${eventName}"), skipping.`);
1360
+ continue;
1361
+ }
1362
+ logger.info(`Unsubscribing Commerce event subscription for "${event.name}" (namespaced: "${eventName}")...`);
1363
+ try {
1364
+ await commerceEventsClient.deleteEventSubscription({ name: eventName });
1365
+ logger.info(`Unsubscribed Commerce event subscription for "${eventName}".`);
1366
+ } catch (error) {
1367
+ logger.warn(`Failed to unsubscribe Commerce event subscription for "${eventName}": ${stringifyError(error)}. Continuing uninstall.`);
1368
+ }
1369
+ }
1370
+ }
1371
+ /**
1372
+ * Deletes a single Commerce-side event provider.
1373
+ * The provider is matched by its deterministic `instance_id`. If not found, deletion is skipped.
1374
+ * Errors are caught and logged so that uninstall remains best-effort.
1375
+ */
1376
+ async function deleteCommerceEventProvider(metadata, provider, existingProviders, context) {
1377
+ const { commerceEventsClient, appData, logger } = context;
1378
+ const instanceId = generateInstanceId(metadata, provider, appData.workspaceId);
1379
+ const instanceIdOldVersion = generateInstanceIdDeprecated(metadata, provider);
1380
+ const commerceProvider = existingProviders.find((p) => p.instance_id === instanceId || p.instance_id === instanceIdOldVersion);
1381
+ if (!commerceProvider) {
1382
+ logger.info(`No Commerce event provider found with instance ID "${instanceId}", skipping provider deletion.`);
1383
+ return;
1384
+ }
1385
+ logger.info(`Deleting Commerce event provider "${provider.label}" (provider_id: ${commerceProvider.provider_id})...`);
1386
+ try {
1387
+ await commerceEventsClient.deleteEventProvider({ provider_id: commerceProvider.provider_id });
1388
+ logger.info(`Deleted Commerce event provider "${provider.label}" (provider_id: ${commerceProvider.provider_id}).`);
1389
+ } catch (error) {
1390
+ logger.warn(`Failed to delete Commerce event provider "${provider.label}" (provider_id: ${commerceProvider.provider_id}): ${stringifyError(error)}. Continuing uninstall.`);
1391
+ }
1392
+ }
1393
+ /**
1394
+ * Offboards Commerce eventing for a single provider. Performs the following steps in order:
1395
+ * 1. Unsubscribes all event subscriptions that were created for the given provider.
1396
+ * 2. Deletes the Commerce-side event provider itself.
1397
+ *
1398
+ * Subscriptions are matched by their namespaced name, which is deterministic and built the
1399
+ * same way as during {@link onboardCommerceEventing}. The provider is matched by its
1400
+ * `instance_id`. Missing subscriptions or providers are silently skipped. All errors are
1401
+ * caught and logged so that uninstall remains best-effort.
1402
+ *
1403
+ * @param params - Configuration identifying the provider and its events to offboard.
1404
+ * @param existingData - Current Commerce eventing data (providers and subscriptions).
1405
+ */
1406
+ async function offboardCommerceEventing(params, existingData) {
1407
+ const { context, metadata, provider, events } = params;
1408
+ await deleteCommerceEventSubscriptions(events, metadata, provider, existingData.subscriptions, context);
1409
+ await deleteCommerceEventProvider(metadata, provider, existingData.providers, context);
1410
+ }
1127
1411
 
1128
1412
  //#endregion
1129
1413
  //#region source/management/installation/events/commerce.ts
@@ -1131,56 +1415,105 @@ async function onboardCommerceEventing(params, existingData) {
1131
1415
  const commerceEventsStep = defineLeafStep({
1132
1416
  name: "commerce",
1133
1417
  meta: {
1134
- label: "Configure Commerce Events",
1135
- description: "Sets up I/O Events for Adobe Commerce event sources"
1418
+ install: {
1419
+ label: "Configure Commerce Events",
1420
+ description: "Sets up I/O Events for Adobe Commerce event sources"
1421
+ },
1422
+ uninstall: {
1423
+ label: "Remove Commerce Events",
1424
+ description: "Removes I/O Events for Adobe Commerce event sources"
1425
+ }
1136
1426
  },
1137
1427
  when: hasCommerceEvents,
1138
- run: async (config, context) => {
1139
- const { logger } = context;
1140
- logger.debug("Starting installation of Commerce Events with config:", config);
1141
- const stepData = [];
1142
- const workspaceConfiguration = JSON.stringify(makeWorkspaceConfig(context));
1143
- const existingIoEventsData = await getIoEventsExistingData(context);
1144
- const commerceEventingExistingData = await getCommerceEventingExistingData(context);
1145
- for (const { provider, events } of config.eventing.commerce) {
1146
- const { providerData, eventsData } = await onboardIoEvents({
1147
- context,
1148
- metadata: config.metadata,
1149
- provider,
1150
- events,
1151
- providerType: COMMERCE_PROVIDER_TYPE
1152
- }, existingIoEventsData);
1153
- const { commerceProvider, subscriptions } = await onboardCommerceEventing({
1154
- context,
1155
- metadata: config.metadata,
1156
- provider,
1157
- ioData: {
1158
- provider: providerData,
1159
- events: eventsData,
1160
- workspaceConfiguration
1161
- }
1162
- }, commerceEventingExistingData);
1163
- stepData.push({ provider: {
1164
- config: provider,
1165
- data: {
1166
- ioEvents: providerData,
1167
- commerce: commerceProvider,
1168
- events: eventsData.map(({ config, data }, index) => {
1169
- return {
1170
- config,
1171
- data: {
1172
- ...data,
1173
- subscription: subscriptions[index]
1174
- }
1175
- };
1176
- })
1177
- }
1178
- } });
1179
- }
1180
- logger.debug("Completed Commerce Events installation step.");
1181
- return stepData;
1182
- }
1428
+ install: createCommerceEvents,
1429
+ uninstall: removeCommerceEvents
1183
1430
  });
1431
+ /**
1432
+ * Creates all needed entities for Eventing to work with Commerce and Adobe I/O Events.
1433
+ * @param config - The configuration of the app, with commerce events.
1434
+ * @param context - The execution context for the events installation.
1435
+ */
1436
+ async function createCommerceEvents(config, context) {
1437
+ const { logger } = context;
1438
+ logger.debug("Starting installation of Commerce Events with config:", config);
1439
+ const stepData = [];
1440
+ const workspaceConfiguration = JSON.stringify(makeWorkspaceConfig(context));
1441
+ const existingIoEventsData = await getIoEventsExistingData(context);
1442
+ const commerceEventingExistingData = await getCommerceEventingExistingData(context);
1443
+ for (let i = 0; i < config.eventing.commerce.length; i++) {
1444
+ const { provider, events } = config.eventing.commerce[i];
1445
+ const { providerData, eventsData } = await onboardIoEvents({
1446
+ context,
1447
+ metadata: config.metadata,
1448
+ provider,
1449
+ events,
1450
+ providerType: COMMERCE_PROVIDER_TYPE
1451
+ }, existingIoEventsData);
1452
+ if (i === 0) await configureCommerceEventing({
1453
+ context,
1454
+ config: {
1455
+ enabled: true,
1456
+ merchant_id: sanitizeEventingIdentifier(context.appData.orgName),
1457
+ environment_id: sanitizeEventingIdentifier(context.appData.projectName),
1458
+ instance_id: providerData.instance_id,
1459
+ workspace_configuration: workspaceConfiguration
1460
+ }
1461
+ }, commerceEventingExistingData);
1462
+ const { commerceProvider, subscriptions } = await onboardCommerceEventing({
1463
+ context,
1464
+ metadata: config.metadata,
1465
+ provider,
1466
+ ioData: {
1467
+ provider: providerData,
1468
+ events: eventsData,
1469
+ workspaceConfiguration
1470
+ }
1471
+ }, commerceEventingExistingData);
1472
+ stepData.push({ provider: {
1473
+ config: provider,
1474
+ data: {
1475
+ ioEvents: providerData,
1476
+ commerce: commerceProvider,
1477
+ events: eventsData.map(({ config, data }, index) => {
1478
+ return {
1479
+ config,
1480
+ data: {
1481
+ ...data,
1482
+ subscription: subscriptions[index]
1483
+ }
1484
+ };
1485
+ })
1486
+ }
1487
+ } });
1488
+ }
1489
+ logger.debug("Completed Commerce Events installation step.");
1490
+ return stepData;
1491
+ }
1492
+ /**
1493
+ * Remove all created for Commerce eventing created durint installation
1494
+ * @param config - The configuration of the app, with commerce events.
1495
+ * @param context - The execution context for the events installation.
1496
+ */
1497
+ async function removeCommerceEvents(config, context) {
1498
+ const { logger } = context;
1499
+ logger.debug("Starting uninstall of Commerce Events with config:", config);
1500
+ const [existingIoEventsData, commerceEventingExistingData] = await Promise.all([getIoEventsExistingData(context), getCommerceEventingExistingData(context)]);
1501
+ for (const { provider, events } of config.eventing.commerce) {
1502
+ await offboardCommerceEventing({
1503
+ context,
1504
+ metadata: config.metadata,
1505
+ provider,
1506
+ events
1507
+ }, commerceEventingExistingData);
1508
+ await offboardIoEvents({
1509
+ context,
1510
+ metadata: config.metadata,
1511
+ provider,
1512
+ events
1513
+ }, existingIoEventsData);
1514
+ }
1515
+ logger.debug("Completed Commerce Events uninstall step.");
1516
+ }
1184
1517
 
1185
1518
  //#endregion
1186
1519
  //#region source/management/installation/events/context.ts
@@ -1196,6 +1529,8 @@ function createCommerceEventsApiClient(params) {
1196
1529
  createEventProvider,
1197
1530
  getAllEventProviders,
1198
1531
  createEventSubscription,
1532
+ deleteEventProvider,
1533
+ deleteEventSubscription,
1199
1534
  getAllEventSubscriptions,
1200
1535
  updateEventingConfiguration
1201
1536
  });
@@ -1212,6 +1547,9 @@ function createIoEventsApiClient(params) {
1212
1547
  createEventProvider: createEventProvider$1,
1213
1548
  createEventMetadataForProvider,
1214
1549
  createRegistration,
1550
+ deleteEventMetadataForProvider,
1551
+ deleteEventProvider: deleteEventProvider$1,
1552
+ deleteRegistration,
1215
1553
  getAllEventProviders: getAllEventProviders$1,
1216
1554
  getAllRegistrations
1217
1555
  });
@@ -1239,38 +1577,68 @@ function createEventsStepContext(installation) {
1239
1577
  const externalEventsStep = defineLeafStep({
1240
1578
  name: "external",
1241
1579
  meta: {
1242
- label: "Configure External Events",
1243
- description: "Sets up I/O Events for external event sources"
1580
+ install: {
1581
+ label: "Configure External Events",
1582
+ description: "Sets up I/O Events for external event sources"
1583
+ },
1584
+ uninstall: {
1585
+ label: "Remove External Events",
1586
+ description: "Removes I/O Events for external event sources"
1587
+ }
1244
1588
  },
1245
1589
  when: hasExternalEvents,
1246
- run: async (config, context) => {
1247
- const { logger } = context;
1248
- logger.debug("Starting installation of External Events with config:", config);
1249
- const stepData = [];
1250
- const existingIoEventsData = await getIoEventsExistingData(context);
1251
- for (const { provider, events } of config.eventing.external) {
1252
- const { providerData, eventsData } = await onboardIoEvents({
1253
- context,
1254
- metadata: config.metadata,
1255
- provider,
1256
- events,
1257
- providerType: EXTERNAL_PROVIDER_TYPE
1258
- }, existingIoEventsData);
1259
- stepData.push({ provider: {
1260
- config: provider,
1261
- data: {
1262
- ioEvents: providerData,
1263
- events: {
1264
- config: events,
1265
- data: eventsData
1266
- }
1590
+ install: createExternalEvents,
1591
+ uninstall: removeExternalEvents
1592
+ });
1593
+ /**
1594
+ * Creates all needed entities for External Events to work with Adobe I/O Events.
1595
+ * @param config - The configuration of the app, with external events.
1596
+ * @param context - The execution context for the events installation.
1597
+ */
1598
+ async function createExternalEvents(config, context) {
1599
+ const { logger } = context;
1600
+ logger.debug("Starting installation of External Events with config:", config);
1601
+ const stepData = [];
1602
+ const existingIoEventsData = await getIoEventsExistingData(context);
1603
+ for (const { provider, events } of config.eventing.external) {
1604
+ const { providerData, eventsData } = await onboardIoEvents({
1605
+ context,
1606
+ metadata: config.metadata,
1607
+ provider,
1608
+ events,
1609
+ providerType: EXTERNAL_PROVIDER_TYPE
1610
+ }, existingIoEventsData);
1611
+ stepData.push({ provider: {
1612
+ config: provider,
1613
+ data: {
1614
+ ioEvents: providerData,
1615
+ events: {
1616
+ config: events,
1617
+ data: eventsData
1267
1618
  }
1268
- } });
1269
- }
1270
- logger.debug("Completed External Events installation step.");
1271
- return stepData;
1619
+ }
1620
+ } });
1272
1621
  }
1273
- });
1622
+ logger.debug("Completed External Events installation step.");
1623
+ return stepData;
1624
+ }
1625
+ /**
1626
+ * Removed all created entities for External Events during the installation
1627
+ * @param config - The configuration of the app, with external events.
1628
+ * @param context - The execution context for the events installation.
1629
+ */
1630
+ async function removeExternalEvents(config, context) {
1631
+ const { logger } = context;
1632
+ logger.debug("Starting uninstall of External Events with config:", config);
1633
+ const existingIoEventsData = await getIoEventsExistingData(context);
1634
+ for (const { provider, events } of config.eventing.external) await offboardIoEvents({
1635
+ context,
1636
+ metadata: config.metadata,
1637
+ provider,
1638
+ events
1639
+ }, existingIoEventsData);
1640
+ logger.debug("Completed External Events uninstall step.");
1641
+ }
1274
1642
 
1275
1643
  //#endregion
1276
1644
  //#region source/management/installation/events/branch.ts
@@ -1278,8 +1646,14 @@ const externalEventsStep = defineLeafStep({
1278
1646
  const eventingStep = defineBranchStep({
1279
1647
  name: "eventing",
1280
1648
  meta: {
1281
- label: "Eventing",
1282
- description: "Sets up the I/O Events and the Commerce events required by the application"
1649
+ install: {
1650
+ label: "Eventing",
1651
+ description: "Sets up the I/O Events and the Commerce events required by the application"
1652
+ },
1653
+ uninstall: {
1654
+ label: "Eventing",
1655
+ description: "Removes the I/O Events and Commerce events configured by the application"
1656
+ }
1283
1657
  },
1284
1658
  when: hasEventing,
1285
1659
  context: createEventsStepContext,
@@ -1298,7 +1672,8 @@ function createCommerceWebhooksApiClient(params) {
1298
1672
  commerceClientParams.fetchOptions.timeout = 1e3 * 60 * 2;
1299
1673
  return createCustomCommerceWebhooksApiClient(commerceClientParams, {
1300
1674
  getWebhookList,
1301
- subscribeWebhook
1675
+ subscribeWebhook,
1676
+ unsubscribeWebhook
1302
1677
  });
1303
1678
  }
1304
1679
  /** Creates the webhooks step context with a lazy-initialized API client. */
@@ -1396,11 +1771,49 @@ async function createWebhookSubscriptions(config, context) {
1396
1771
  return { subscribedWebhooks };
1397
1772
  }
1398
1773
  /**
1774
+ * Unsubscribes each webhook from the app config in Adobe Commerce.
1775
+ * If a webhook is not found in the existing list, it is silently skipped (idempotent).
1776
+ *
1777
+ * @param config - The app config (must have a non-empty `webhooks` array).
1778
+ * @param context - The webhooks execution context (provides the Commerce API client and logger).
1779
+ */
1780
+ async function deleteWebhookSubscriptions(config, context) {
1781
+ const { logger, commerceWebhooksClient } = context;
1782
+ logger.info(`Unsubscribing ${config.webhooks.length} webhook(s) from Commerce...`);
1783
+ const idPrefix = buildWebhookIdPrefix(config.metadata.id);
1784
+ const unsubscribedWebhooks = [];
1785
+ const existingWebhooks = await commerceWebhooksClient.getWebhookList();
1786
+ for (const entry of config.webhooks) {
1787
+ const { webhook } = entry;
1788
+ const resolvedBatch = `${idPrefix}${webhook.batch_name}`;
1789
+ const resolvedHook = `${idPrefix}${webhook.hook_name}`;
1790
+ const params = {
1791
+ webhook_method: webhook.webhook_method,
1792
+ webhook_type: webhook.webhook_type,
1793
+ batch_name: resolvedBatch,
1794
+ hook_name: resolvedHook
1795
+ };
1796
+ if (!isWebhookInList(existingWebhooks, params)) {
1797
+ logger.debug(`Webhook not found, skipping unsubscribe: ${getWebhookName(webhook)}`);
1798
+ continue;
1799
+ }
1800
+ try {
1801
+ await deleteWebhookSubscription(commerceWebhooksClient, webhook, params);
1802
+ logger.info(`Unsubscribed webhook: ${getWebhookName(webhook)}`);
1803
+ unsubscribedWebhooks.push(params);
1804
+ } catch (error) {
1805
+ logger.warn(`Failed to unsubscribe webhook "${getWebhookName(webhook)}": ${stringifyError(error)}. Continuing uninstall.`);
1806
+ }
1807
+ }
1808
+ logger.info(`Webhook unsubscriptions complete: ${unsubscribedWebhooks.length} unsubscribed.`);
1809
+ return { unsubscribedWebhooks };
1810
+ }
1811
+ /**
1399
1812
  * Subscribes a single webhook to Commerce, skipping the API call if the webhook
1400
1813
  * is already subscribed (matched by webhook_method, webhook_type, batch_name, hook_name).
1401
1814
  */
1402
1815
  async function createOrGetWebhookSubscription(existingWebhooks, client, resolvedWebhook, logger) {
1403
- if (isAlreadySubscribed(existingWebhooks, resolvedWebhook)) {
1816
+ if (isWebhookInList(existingWebhooks, resolvedWebhook)) {
1404
1817
  logger.info(`Webhook already subscribed, skipping: ${getWebhookName(resolvedWebhook)}`);
1405
1818
  return resolvedWebhook;
1406
1819
  }
@@ -1409,6 +1822,22 @@ async function createOrGetWebhookSubscription(existingWebhooks, client, resolved
1409
1822
  return subscribed;
1410
1823
  }
1411
1824
  /**
1825
+ * Re-throws `err`, enriching the message with the webhook name if the error is an
1826
+ * `HTTPError` with a JSON body containing a string `message` field.
1827
+ */
1828
+ async function rethrowWithWebhookName(err, webhookName, operation) {
1829
+ if (err instanceof HTTPError) {
1830
+ let body;
1831
+ try {
1832
+ body = await err.response.json();
1833
+ } catch {
1834
+ throw err;
1835
+ }
1836
+ if (typeof body?.message === "string") throw new Error(`Webhook ${operation} failed for "${webhookName}": ${body.message}`);
1837
+ }
1838
+ throw err;
1839
+ }
1840
+ /**
1412
1841
  * Subscribes a single webhook to Commerce, enriching the error with the webhook name
1413
1842
  * if the API responds with a string `message`.
1414
1843
  */
@@ -1417,16 +1846,18 @@ async function createWebhookSubscription(client, resolvedWebhook) {
1417
1846
  await client.subscribeWebhook(resolvedWebhook);
1418
1847
  return resolvedWebhook;
1419
1848
  } catch (err) {
1420
- if (err instanceof HTTPError) {
1421
- let body;
1422
- try {
1423
- body = await err.response.json();
1424
- } catch {
1425
- throw err;
1426
- }
1427
- if (typeof body?.message === "string") throw new Error(`Webhook subscription failed for "${getWebhookName(resolvedWebhook)}": ${body.message}`);
1428
- }
1429
- throw err;
1849
+ return await rethrowWithWebhookName(err, getWebhookName(resolvedWebhook), "subscription");
1850
+ }
1851
+ }
1852
+ /**
1853
+ * Unsubscribes a single webhook from Commerce, enriching the error with the webhook name
1854
+ * if the API responds with a string `message`.
1855
+ */
1856
+ async function deleteWebhookSubscription(client, resolvedWebhook, params) {
1857
+ try {
1858
+ await client.unsubscribeWebhook(params);
1859
+ } catch (err) {
1860
+ await rethrowWithWebhookName(err, getWebhookName(resolvedWebhook), "unsubscription");
1430
1861
  }
1431
1862
  }
1432
1863
  /**
@@ -1447,14 +1878,14 @@ function resolveDeveloperConsoleOAuthCredentials(params) {
1447
1878
  };
1448
1879
  }
1449
1880
  /**
1450
- * Returns true when the candidate webhook is already present in the existing subscription list,
1451
- * matched by the four-part identity: webhook_method, webhook_type, batch_name, hook_name.
1881
+ * Returns true when a webhook with the given four-part identity exists in the list.
1452
1882
  *
1883
+ * The identity check uses: webhook_method, webhook_type, batch_name, hook_name.
1453
1884
  * `webhook_method` is normalised before comparison to handle the case where Commerce strips the
1454
1885
  * `.magento` segment from plugin webhook methods on storage
1455
1886
  * (e.g. `plugin.magento.foo` and `plugin.foo` are treated as the same method).
1456
1887
  */
1457
- function isAlreadySubscribed(existing, candidate) {
1888
+ function isWebhookInList(existing, candidate) {
1458
1889
  const normalizedCandidate = normalizeWebhookMethod(candidate.webhook_method);
1459
1890
  return existing.some((w) => normalizeWebhookMethod(w.webhook_method) === normalizedCandidate && w.webhook_type === candidate.webhook_type && w.batch_name === candidate.batch_name && w.hook_name === candidate.hook_name);
1460
1891
  }
@@ -1512,18 +1943,33 @@ function getWebhookName(webhook) {
1512
1943
  const subscriptionsStep = defineLeafStep({
1513
1944
  name: "subscriptions",
1514
1945
  meta: {
1515
- label: "Create Subscriptions",
1516
- description: "Creates webhook subscriptions in Adobe Commerce"
1946
+ install: {
1947
+ label: "Create Subscriptions",
1948
+ description: "Creates webhook subscriptions in Adobe Commerce"
1949
+ },
1950
+ uninstall: {
1951
+ label: "Delete Subscriptions",
1952
+ description: "Deletes webhook subscriptions from Adobe Commerce"
1953
+ }
1517
1954
  },
1518
1955
  validate: (config, context) => validateWebhookConflicts(config, context),
1519
- run: (config, context) => createWebhookSubscriptions(config, context)
1956
+ install: (config, context) => createWebhookSubscriptions(config, context),
1957
+ uninstall: async (config, context) => {
1958
+ await deleteWebhookSubscriptions(config, context);
1959
+ }
1520
1960
  });
1521
1961
  /** Branch step for setting up Commerce webhooks. */
1522
1962
  const webhooksStep = defineBranchStep({
1523
1963
  name: "webhooks",
1524
1964
  meta: {
1525
- label: "Webhooks",
1526
- description: "Sets up Commerce webhooks"
1965
+ install: {
1966
+ label: "Webhooks",
1967
+ description: "Sets up Commerce webhooks"
1968
+ },
1969
+ uninstall: {
1970
+ label: "Webhooks",
1971
+ description: "Removes Commerce webhooks"
1972
+ }
1527
1973
  },
1528
1974
  when: hasWebhooks,
1529
1975
  context: createWebhooksStepContext,
@@ -1549,10 +1995,23 @@ function createDefaultChildSteps(config) {
1549
1995
  function createRootInstallationStep(config) {
1550
1996
  return defineBranchStep({
1551
1997
  name: "installation",
1552
- meta: {
1998
+ meta: { install: {
1553
1999
  label: "Installation",
1554
2000
  description: "App installation workflow"
1555
- },
2001
+ } },
2002
+ children: createDefaultChildSteps(config)
2003
+ });
2004
+ }
2005
+ /**
2006
+ * Creates a root uninstallation step with dynamic children based on the config.
2007
+ */
2008
+ function createRootUninstallationStep(config) {
2009
+ return defineBranchStep({
2010
+ name: "uninstallation",
2011
+ meta: { install: {
2012
+ label: "Uninstallation",
2013
+ description: "App uninstallation workflow"
2014
+ } },
1556
2015
  children: createDefaultChildSteps(config)
1557
2016
  });
1558
2017
  }
@@ -1585,6 +2044,30 @@ function runInstallation(options) {
1585
2044
  });
1586
2045
  }
1587
2046
  /**
2047
+ * Creates an initial uninstallation state from the config and step definitions.
2048
+ */
2049
+ function createInitialUninstallationState(options) {
2050
+ const { config } = options;
2051
+ return createInitialState({
2052
+ rootStep: createRootUninstallationStep(config),
2053
+ config,
2054
+ mode: "uninstall"
2055
+ });
2056
+ }
2057
+ /**
2058
+ * Runs the full uninstallation workflow. Returns the final state (never throws).
2059
+ */
2060
+ function runUninstallation(options) {
2061
+ const { installationContext, config, initialState, hooks } = options;
2062
+ return executeUninstallWorkflow({
2063
+ rootStep: createRootUninstallationStep(config),
2064
+ installationContext,
2065
+ config,
2066
+ initialState,
2067
+ hooks
2068
+ });
2069
+ }
2070
+ /**
1588
2071
  * Runs pre-installation validation over the full step tree.
1589
2072
  *
1590
2073
  * Traverses the same step hierarchy used during installation but only calls
@@ -1602,4 +2085,4 @@ function runValidation(options) {
1602
2085
  }
1603
2086
 
1604
2087
  //#endregion
1605
- export { isCompletedState as a, isSucceededState as c, defineCustomInstallationStep as i, runInstallation as n, isFailedState as o, runValidation as r, isInProgressState as s, createInitialInstallationState as t };
2088
+ export { runValidation as a, isFailedState as c, runUninstallation as i, isInProgressState as l, createInitialUninstallationState as n, defineCustomInstallationStep as o, runInstallation as r, isCompletedState as s, createInitialInstallationState as t, isSucceededState as u };