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